refactor(aibox): 抽离ConversationHistory至subsystems(Wave2阶段1)
- 新增 subsystems/conversation_history.py: 迁移对话历史管理器(318行),MemoryDatabase用TYPE_CHECKING注解避免循环导入 - subsystems/__init__.py: 导出ConversationHistory,更新docstring含Wave2 - aibox.py: 移除ConversationHistory类定义(3706->3388行),仅保留import与类型注解 - 新增 tests/test_conversation_history.py: 41项单测,覆盖构造/当前对话/归档/历史加载/memory_db集成 - 测试基线: 171->212项全过(12.13s),无回归
This commit is contained in:
1 parent
8cb822b511
commit
85133868f3
4 files changed
+782
-324
No files matched your search
@@ -7,6 +7,8 @@ subsystems - aibox 子系统模块(包)
|
||||
- prompt_manager.py : PromptManager(单例)
|
||||
- db.py : MemoryDatabase + MemoDatabase
|
||||
- skill_manager.py : SkillManager
|
||||
Wave 2:
|
||||
- conversation_history.py : ConversationHistory(对话历史管理)
|
||||
|
||||
依赖方向:common <- subsystems <- aibox
|
||||
不反向依赖 aibox 任何符号。
|
||||
@@ -19,6 +21,7 @@ from .executors import CommandExecutor, SubprocessCommandExecutor
|
||||
from .prompt_manager import PromptManager
|
||||
from .db import MemoryDatabase, MemoDatabase
|
||||
from .skill_manager import SkillManager
|
||||
from .conversation_history import ConversationHistory
|
||||
|
||||
__all__ = [
|
||||
"CommandExecutor",
|
||||
@@ -27,4 +30,5 @@ __all__ = [
|
||||
"MemoryDatabase",
|
||||
"MemoDatabase",
|
||||
"SkillManager",
|
||||
"ConversationHistory",
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
subsystems.conversation_history - 对话历史管理器
|
||||
|
||||
抽离自 aibox.py,管理当前对话与归档历史,提供上下文加载能力。
|
||||
|
||||
职责:
|
||||
- 维护当前对话(current_conversation.json)
|
||||
- 归档历史对话(archive/*.json)+ 历史索引(conversation_history.json)
|
||||
- 加载历史消息作为 AI 上下文
|
||||
- 归档/结束时可选写入 MemoryDatabase
|
||||
|
||||
依赖:common (constants, ConfigManager, Logger) + subsystems.db (MemoryDatabase) + 标准库
|
||||
不反向依赖 aibox 任何符号。
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
|
||||
from common import ConfigManager, Logger, constants
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# 仅用于类型注解,避免运行时循环导入;MemoryDatabase 来自同包 subsystems.db
|
||||
from subsystems.db import MemoryDatabase
|
||||
|
||||
|
||||
class ConversationHistory:
|
||||
"""对话历史管理器 - 管理所有历史对话"""
|
||||
|
||||
def __init__(self, save_dir: str = None, memory_db: "MemoryDatabase" = None, load_history: bool = None):
|
||||
if save_dir is None:
|
||||
save_dir = constants.SAVE_DIR
|
||||
|
||||
self.save_dir = Path(save_dir)
|
||||
self.archive_dir = self.save_dir / constants.ARCHIVE_DIR
|
||||
self.history_file = self.save_dir / constants.HISTORY_FILE
|
||||
self.memory_db = memory_db
|
||||
|
||||
config = ConfigManager()
|
||||
if load_history is None:
|
||||
load_history = config.get("system.load_history_by_default", True)
|
||||
self.load_history = load_history
|
||||
|
||||
# 确保目录存在
|
||||
self.save_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.logger = Logger("HistoryManager")
|
||||
self.history_index = self._load_history_index()
|
||||
self.current_conversation = None
|
||||
self.current_file = self.save_dir / constants.CURRENT_CONVERSATION_FILE
|
||||
|
||||
self._load_current_conversation()
|
||||
|
||||
def _load_history_index(self) -> Dict:
|
||||
"""加载历史索引文件"""
|
||||
if self.history_file.exists():
|
||||
try:
|
||||
with open(self.history_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {"conversations": [], "last_id": 0}
|
||||
return {"conversations": [], "last_id": 0}
|
||||
|
||||
def _save_history_index(self):
|
||||
"""保存历史索引"""
|
||||
with open(self.history_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.history_index, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _load_current_conversation(self):
|
||||
"""加载当前的对话(如果有)"""
|
||||
if self.current_file.exists():
|
||||
try:
|
||||
with open(self.current_file, 'r', encoding='utf-8') as f:
|
||||
self.current_conversation = json.load(f)
|
||||
self.logger.info(f"已加载当前对话: {self.current_conversation.get('topic', '未命名')}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"加载当前对话失败: {e}")
|
||||
|
||||
def save_current_conversation(self):
|
||||
"""保存当前对话"""
|
||||
if self.current_conversation:
|
||||
with open(self.current_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def start_new_conversation(self, topic: str = None) -> Dict:
|
||||
"""开始新对话"""
|
||||
if self.current_conversation:
|
||||
self.archive_current_conversation()
|
||||
|
||||
conv_id = str(int(time.time()))
|
||||
self.current_conversation = {
|
||||
"id": conv_id,
|
||||
"start_time": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||||
"end_time": None,
|
||||
"topic": topic or "新对话",
|
||||
"messages": [],
|
||||
"tool_calls": [],
|
||||
"stats": {
|
||||
"user_messages": 0,
|
||||
"ai_messages": 0,
|
||||
"tool_calls": 0
|
||||
}
|
||||
}
|
||||
|
||||
self.save_current_conversation()
|
||||
self.logger.info(f"开始新对话: {topic}")
|
||||
return self.current_conversation
|
||||
|
||||
def archive_current_conversation(self, final_summary: str = None):
|
||||
"""将当前对话归档到历史"""
|
||||
if not self.current_conversation:
|
||||
return None
|
||||
|
||||
if self.current_conversation.get("end_time") is None:
|
||||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
if final_summary and self.memory_db:
|
||||
messages = self.current_conversation.get("messages", [])
|
||||
content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}"
|
||||
for m in messages[-10:] if m.get('speaker')])
|
||||
|
||||
self.memory_db.add_memory(
|
||||
topic=self.current_conversation["topic"],
|
||||
summary=final_summary,
|
||||
content=content_preview,
|
||||
start_time=self.current_conversation["start_time"],
|
||||
end_time=self.current_conversation["end_time"],
|
||||
importance=2
|
||||
)
|
||||
|
||||
timestamp = datetime.now().strftime(constants.DATE_FORMAT)
|
||||
archive_filename = f"conversation_{timestamp}.json"
|
||||
archive_path = self.archive_dir / archive_filename
|
||||
|
||||
with open(archive_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.current_conversation, f, ensure_ascii=False, indent=2)
|
||||
|
||||
self.history_index["conversations"].append({
|
||||
"id": self.current_conversation["id"],
|
||||
"file": archive_filename,
|
||||
"start_time": self.current_conversation["start_time"],
|
||||
"end_time": self.current_conversation["end_time"],
|
||||
"topic": self.current_conversation["topic"],
|
||||
"messages": len(self.current_conversation["messages"]),
|
||||
"tool_calls": len(self.current_conversation["tool_calls"])
|
||||
})
|
||||
|
||||
self._save_history_index()
|
||||
self.logger.info(f"对话已归档: {archive_filename}")
|
||||
|
||||
self.current_conversation = None
|
||||
if self.current_file.exists():
|
||||
self.current_file.unlink()
|
||||
|
||||
return str(archive_path)
|
||||
|
||||
def load_all_history(self, max_messages: int = None) -> List[Dict]:
|
||||
"""加载所有历史对话作为上下文"""
|
||||
if max_messages is None:
|
||||
max_messages = constants.MAX_HISTORY_CONTEXT
|
||||
|
||||
if not self.load_history:
|
||||
return []
|
||||
|
||||
all_messages = []
|
||||
|
||||
if self.current_conversation:
|
||||
current_msgs = self.current_conversation.get("messages", [])
|
||||
for msg in current_msgs[-max_messages//2:]:
|
||||
if not msg.get("is_history"):
|
||||
all_messages.append({
|
||||
"timestamp": msg["timestamp"],
|
||||
"role": msg["role"],
|
||||
"speaker": msg.get("speaker"),
|
||||
"content": msg["content"],
|
||||
"from_current": True
|
||||
})
|
||||
|
||||
history_files = sorted(self.archive_dir.glob("*.json"), reverse=True)[:constants.MAX_HISTORY_CONVERSATIONS]
|
||||
|
||||
for hist_file in history_files:
|
||||
try:
|
||||
with open(hist_file, 'r', encoding='utf-8') as f:
|
||||
conv = json.load(f)
|
||||
messages = conv.get("messages", [])
|
||||
for msg in messages[-max_messages//len(history_files):]:
|
||||
if not msg.get("is_history"):
|
||||
all_messages.append({
|
||||
"timestamp": msg["timestamp"],
|
||||
"role": msg["role"],
|
||||
"speaker": msg.get("speaker"),
|
||||
"content": msg["content"],
|
||||
"from_file": hist_file.name
|
||||
})
|
||||
except Exception as e:
|
||||
self.logger.error(f"加载历史文件 {hist_file} 失败: {e}")
|
||||
|
||||
all_messages.sort(key=lambda x: x["timestamp"])
|
||||
|
||||
if len(all_messages) > max_messages:
|
||||
all_messages = all_messages[-max_messages:]
|
||||
|
||||
self.logger.info(f"已加载 {len(all_messages)} 条历史消息")
|
||||
return all_messages
|
||||
|
||||
def get_conversation_context(self, max_messages: int = None) -> List[Dict]:
|
||||
"""获取适合作为AI上下文的对话历史"""
|
||||
if max_messages is None:
|
||||
max_messages = constants.MAX_HISTORY_CONTEXT
|
||||
|
||||
if not self.load_history:
|
||||
return []
|
||||
|
||||
history_messages = self.load_all_history(max_messages)
|
||||
|
||||
context = []
|
||||
for msg in history_messages:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
speaker = msg.get("speaker", "")
|
||||
context.append({
|
||||
"role": "assistant",
|
||||
"content": f"[{speaker}] {content}"
|
||||
})
|
||||
else:
|
||||
context.append({
|
||||
"role": "user",
|
||||
"content": content
|
||||
})
|
||||
|
||||
return context
|
||||
|
||||
def add_message(self, role: str, content: str, speaker: str = None):
|
||||
"""添加消息到当前对话"""
|
||||
if not self.current_conversation:
|
||||
self.start_new_conversation()
|
||||
|
||||
message = {
|
||||
"timestamp": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||||
"role": role,
|
||||
"speaker": speaker,
|
||||
"content": content
|
||||
}
|
||||
|
||||
self.current_conversation["messages"].append(message)
|
||||
|
||||
if role == "user":
|
||||
self.current_conversation["stats"]["user_messages"] += 1
|
||||
elif role == "assistant":
|
||||
self.current_conversation["stats"]["ai_messages"] += 1
|
||||
|
||||
self.save_current_conversation()
|
||||
|
||||
def add_tool_call(self, tool_name: str, args: Dict, result: str):
|
||||
"""记录工具调用"""
|
||||
if not self.current_conversation:
|
||||
return
|
||||
|
||||
tool_call = {
|
||||
"timestamp": datetime.now().strftime(constants.DATETIME_FORMAT),
|
||||
"tool": tool_name,
|
||||
"args": args,
|
||||
"result_preview": result[:200] + "..." if len(result) > 200 else result
|
||||
}
|
||||
|
||||
self.current_conversation["tool_calls"].append(tool_call)
|
||||
self.current_conversation["stats"]["tool_calls"] += 1
|
||||
self.save_current_conversation()
|
||||
|
||||
def end_current_conversation(self, summary: str = None):
|
||||
"""结束当前对话"""
|
||||
if self.current_conversation:
|
||||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
|
||||
if summary and self.memory_db:
|
||||
messages = self.current_conversation.get("messages", [])
|
||||
content_preview = "\n".join([f"{m['speaker']}: {m['content'][:200]}"
|
||||
for m in messages[-10:] if m.get('speaker')])
|
||||
|
||||
self.memory_db.add_memory(
|
||||
topic=self.current_conversation["topic"],
|
||||
summary=summary,
|
||||
content=content_preview,
|
||||
start_time=self.current_conversation["start_time"],
|
||||
end_time=self.current_conversation["end_time"],
|
||||
importance=2
|
||||
)
|
||||
|
||||
self.save_current_conversation()
|
||||
self.logger.info("当前对话已保存")
|
||||
|
||||
def reset_conversation(self):
|
||||
"""重置当前对话状态"""
|
||||
if self.current_conversation:
|
||||
self.current_conversation["end_time"] = datetime.now().strftime(constants.DATETIME_FORMAT)
|
||||
self.save_current_conversation()
|
||||
self.current_conversation = None
|
||||
|
||||
def list_conversations(self) -> List[Dict]:
|
||||
"""列出所有对话(包括当前和归档)"""
|
||||
conversations = []
|
||||
|
||||
if self.current_conversation:
|
||||
conversations.append({
|
||||
"file": constants.CURRENT_CONVERSATION_FILE,
|
||||
"is_current": True,
|
||||
"id": self.current_conversation.get("id"),
|
||||
"start_time": self.current_conversation.get("start_time"),
|
||||
"end_time": self.current_conversation.get("end_time"),
|
||||
"topic": self.current_conversation.get("topic"),
|
||||
"messages": len(self.current_conversation.get("messages", [])),
|
||||
"tool_calls": len(self.current_conversation.get("tool_calls", []))
|
||||
})
|
||||
|
||||
for conv in self.history_index.get("conversations", []):
|
||||
conversations.append({
|
||||
"file": conv["file"],
|
||||
"is_current": False,
|
||||
"id": conv["id"],
|
||||
"start_time": conv["start_time"],
|
||||
"end_time": conv["end_time"],
|
||||
"topic": conv["topic"],
|
||||
"messages": conv["messages"],
|
||||
"tool_calls": conv["tool_calls"]
|
||||
})
|
||||
|
||||
conversations.sort(key=lambda x: x["start_time"] if x["start_time"] else "", reverse=True)
|
||||
return conversations
|
||||
|
||||
def load_conversation(self, file_name: str) -> Optional[Dict]:
|
||||
"""加载指定的对话"""
|
||||
if file_name == constants.CURRENT_CONVERSATION_FILE and self.current_conversation:
|
||||
return self.current_conversation
|
||||
|
||||
file_path = self.archive_dir / file_name
|
||||
if file_path.exists():
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
Reference in new issue
Block a user