Files
blackbird/aibox/subsystems/conversation_history.py
T
qing cccc946660 fix(aibox): batch G audit fixes - llm hardening, thread lifecycle, cancel propagation, db transactions
P-series (llm_client): truncate non-transient HTTP error body to 200 chars, cap Retry-After at 3600s, validate fallback profiles (missing model skipped with warning), dedupe fallback list, remove dead _cancelled flag, document FailoverClient concurrency contract.

N-series (aibox/registry): batch-level drain thread with try/finally reclamation (N2), subagent cancel propagation via shared parent event + watcher thread (N5), /new reset moved off event loop (N3), streaming forward bounded by TOOL_MAX_RESULT_LENGTH (N8), persona path boundary check (N12), broadcast except narrowed + discard (N13), per-connection task set registration (N14).

db/history: FTS5 quote escaping (N7), mark_as_triggered single BEGIN IMMEDIATE transaction (N10), LIMIT parameterization (L1), archive filename collision suffix (N16).

Tests: +20 cases in test_g_fixes.py, full suite 375 passed.
2026-08-18 07:21:29 +08:00

358 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 Exception as e:
# N16:收窄为 Exception——裸 except 连 KeyboardInterrupt/SystemExit
# 也吞掉;损坏时重置为空索引并告警
self.logger.warning(f"历史索引文件损坏,已重置: {e}")
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
)
# N16:归档文件名加序号防同秒覆盖——DATE_FORMAT 精度为秒,
# 同一秒内两次归档(如测试/快速重置)会静默覆盖前一份
timestamp = datetime.now().strftime(constants.DATE_FORMAT)
archive_filename = f"conversation_{timestamp}.json"
counter = 1
while (self.archive_dir / archive_filename).exists():
archive_filename = f"conversation_{timestamp}_{counter}.json"
counter += 1
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