Add gfa_core/mas/memory module

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
potaser
2026-08-07 09:52:43 +08:00
co-authored by Claude Opus 5
parent c389e90e64
commit 842455dd78
8 changed files with 1177 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
from gfa_core.mas.memory.base import BaseMemory, MemoryEntry, SearchResult
from gfa_core.mas.memory.case import AnalysisCase, CaseMemory
from gfa_core.mas.memory.knowledge import KnowledgeEntry, KnowledgeMemory
from gfa_core.mas.memory.manager import MemoryManager, MemoryStats
from gfa_core.mas.memory.session import SessionData, SessionMemory
from gfa_core.mas.memory.persistent import PersistentMemory
__all__ = [
"BaseMemory",
"MemoryEntry",
"SearchResult",
"SessionData",
"SessionMemory",
"AnalysisCase",
"CaseMemory",
"KnowledgeEntry",
"KnowledgeMemory",
"MemoryManager",
"MemoryStats",
"PersistentMemory",
]
+46
View File
@@ -0,0 +1,46 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Generic, TypeVar
T = TypeVar("T")
@dataclass
class MemoryEntry(Generic[T]):
key: str
value: T
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
ttl: int | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class SearchResult:
key: str
value: Any
score: float
metadata: dict[str, Any] = field(default_factory=dict)
class BaseMemory(ABC):
@abstractmethod
async def store(self, key: str, value: Any, ttl: int | None = None) -> None:
pass
@abstractmethod
async def retrieve(self, key: str) -> Any | None:
pass
@abstractmethod
async def delete(self, key: str) -> bool:
pass
@abstractmethod
async def exists(self, key: str) -> bool:
pass
@abstractmethod
async def clear(self) -> None:
pass
+181
View File
@@ -0,0 +1,181 @@
import json
import sqlite3
import os
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from gfa_core.mas.memory.base import BaseMemory, SearchResult
from gfa_core.project_paths import MEMORY_ROOT
@dataclass
class AnalysisCase:
case_id: str
user_id: str | None = None
gene_family: str | None = None
species: str | None = None
workflow: list[dict] = field(default_factory=list)
results: dict[str, Any] = field(default_factory=dict)
status: str = "pending"
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
completed_at: datetime | None = None
feedback: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
class CaseMemory(BaseMemory):
def __init__(self, db_path: str = None):
if db_path is None:
db_path = os.environ.get("BBAV5_CASE_DB") or MEMORY_ROOT / "cases.db"
self._db_path = Path(db_path)
self._db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
print(f"[CaseMemory] SQLite storage: {self._db_path}")
def _init_db(self) -> None:
with sqlite3.connect(self._db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS cases (
case_id TEXT PRIMARY KEY,
user_id TEXT,
gene_family TEXT,
species TEXT,
workflow TEXT,
results TEXT,
status TEXT,
created_at TEXT,
updated_at TEXT,
completed_at TEXT,
feedback TEXT,
metadata TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_user_id ON cases(user_id)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_gene_family ON cases(gene_family)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_status ON cases(status)
""")
async def store(self, key: str, value: Any, ttl: int | None = None) -> None:
if isinstance(value, AnalysisCase):
case = value
else:
case = AnalysisCase(case_id=key, **value)
case.updated_at = datetime.now()
with sqlite3.connect(self._db_path) as conn:
conn.execute("""
INSERT OR REPLACE INTO cases
(case_id, user_id, gene_family, species, workflow, results, status,
created_at, updated_at, completed_at, feedback, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
case.case_id,
case.user_id,
case.gene_family,
case.species,
json.dumps(case.workflow),
json.dumps(case.results),
case.status,
case.created_at.isoformat(),
case.updated_at.isoformat(),
case.completed_at.isoformat() if case.completed_at else None,
json.dumps(case.feedback),
json.dumps(case.metadata),
))
async def retrieve(self, key: str) -> Any | None:
with sqlite3.connect(self._db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM cases WHERE case_id = ?", (key,)
)
row = cursor.fetchone()
if row is None:
return None
return self._row_to_case(row)
async def delete(self, key: str) -> bool:
with sqlite3.connect(self._db_path) as conn:
cursor = conn.execute(
"DELETE FROM cases WHERE case_id = ?", (key,)
)
return cursor.rowcount > 0
async def exists(self, key: str) -> bool:
with sqlite3.connect(self._db_path) as conn:
cursor = conn.execute(
"SELECT 1 FROM cases WHERE case_id = ?", (key,)
)
return cursor.fetchone() is not None
async def clear(self) -> None:
with sqlite3.connect(self._db_path) as conn:
conn.execute("DELETE FROM cases")
def _row_to_case(self, row: sqlite3.Row) -> AnalysisCase:
return AnalysisCase(
case_id=row["case_id"],
user_id=row["user_id"],
gene_family=row["gene_family"],
species=row["species"],
workflow=json.loads(row["workflow"]) if row["workflow"] else [],
results=json.loads(row["results"]) if row["results"] else {},
status=row["status"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
completed_at=datetime.fromisoformat(row["completed_at"]) if row["completed_at"] else None,
feedback=json.loads(row["feedback"]) if row["feedback"] else {},
metadata=json.loads(row["metadata"]) if row["metadata"] else {},
)
async def list_by_user(self, user_id: str) -> list[AnalysisCase]:
with sqlite3.connect(self._db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM cases WHERE user_id = ? ORDER BY created_at DESC",
(user_id,)
)
return [self._row_to_case(row) for row in cursor.fetchall()]
async def list_by_gene_family(self, gene_family: str) -> list[AnalysisCase]:
with sqlite3.connect(self._db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM cases WHERE gene_family = ? ORDER BY created_at DESC",
(gene_family,)
)
return [self._row_to_case(row) for row in cursor.fetchall()]
async def list_by_status(self, status: str) -> list[AnalysisCase]:
with sqlite3.connect(self._db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"SELECT * FROM cases WHERE status = ? ORDER BY created_at DESC",
(status,)
)
return [self._row_to_case(row) for row in cursor.fetchall()]
async def update_status(self, case_id: str, status: str) -> None:
case = await self.retrieve(case_id)
if case:
case.status = status
if status == "completed":
case.completed_at = datetime.now()
await self.store(case_id, case)
async def add_feedback(self, case_id: str, feedback: dict[str, Any]) -> None:
case = await self.retrieve(case_id)
if case:
case.feedback.update(feedback)
await self.store(case_id, case)
+338
View File
@@ -0,0 +1,338 @@
"""
Conversation Manager - 类似ChatGPT的多会话管理
支持用户维度、会话列表、历史消息
"""
import asyncio
import json
import os
from dataclasses import dataclass, field, asdict
from datetime import datetime
from pathlib import Path
from typing import Any, Optional, List
from enum import Enum
from gfa_core.project_paths import MEMORY_ROOT
class ConversationStatus(str, Enum):
"""会话状态 - 使用str继承以便JSON序列化"""
ACTIVE = "active"
ARCHIVED = "archived"
DELETED = "deleted"
@dataclass
class Conversation:
"""会话(类似ChatGPT的一个聊天窗口)"""
conversation_id: str
user_id: str
title: str
status: str = "active" # 使用str而不是Enum
# 关联信息
gene_family: Optional[str] = None
species: Optional[str] = None
job_id: Optional[str] = None
input_dir: Optional[str] = None
output_dir: Optional[str] = None
working_dir: Optional[str] = None
# 消息历史
messages: List[dict] = field(default_factory=list)
# 元数据
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
message_count: int = 0
# 设置
model: str = "glm-5"
system_prompt: Optional[str] = None
@dataclass
class ConversationSummary:
"""会话摘要(用于列表展示)"""
conversation_id: str
title: str
last_message: str
updated_at: str
message_count: int
gene_family: Optional[str] = None
job_id: Optional[str] = None
input_dir: Optional[str] = None
output_dir: Optional[str] = None
working_dir: Optional[str] = None
class ConversationManager:
"""
会话管理器
功能:
1. 创建/删除/归档会话
2. 按用户列出所有会话
3. 获取会话消息历史
4. 自动更新会话标题
"""
def __init__(self, storage_dir: Path = None):
if storage_dir is None:
storage_dir = Path(os.environ.get("BBAV5_CONVERSATION_DIR") or MEMORY_ROOT / "conversations")
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
# 用户索引:user_id -> list of conversation_ids
self._user_index_file = self.storage_dir / "user_index.json"
self._user_index: dict = {}
self._load_user_index()
self._lock = asyncio.Lock()
print(f"[ConversationManager] Storage: {self.storage_dir}")
def _load_user_index(self):
"""加载用户索引"""
if self._user_index_file.exists():
try:
with open(self._user_index_file, 'r', encoding='utf-8') as f:
self._user_index = json.load(f)
except Exception as e:
print(f"[ConversationManager] Failed to load index: {e}")
self._user_index = {}
async def _save_user_index(self):
"""保存用户索引"""
async with self._lock:
with open(self._user_index_file, 'w', encoding='utf-8') as f:
json.dump(self._user_index, f, ensure_ascii=False, indent=2)
def _get_conv_file(self, conversation_id: str) -> Path:
"""获取会话文件路径。
Existing conversation ids contain their creation date, e.g.
conv_20260524_172646_default. Resolve that month first so old chats
remain readable after the calendar month changes.
"""
match = None
try:
import re
match = re.match(r"^conv_(\d{6})\d{2}_", str(conversation_id or ""))
except Exception:
match = None
candidate_months = []
if match:
candidate_months.append(match.group(1))
candidate_months.append(datetime.now().strftime("%Y%m"))
seen = set()
for date_prefix in candidate_months:
if date_prefix in seen:
continue
seen.add(date_prefix)
dir_path = self.storage_dir / date_prefix
path = dir_path / f"{conversation_id}.json"
if path.exists():
return path
dir_path = self.storage_dir / (candidate_months[0] if candidate_months else datetime.now().strftime("%Y%m"))
dir_path.mkdir(exist_ok=True)
return dir_path / f"{conversation_id}.json"
async def create_conversation(
self,
user_id: str,
title: str = None,
gene_family: str = None,
species: str = None,
system_prompt: str = None,
job_id: str = None,
input_dir: str = None,
output_dir: str = None,
working_dir: str = None,
) -> Conversation:
"""创建新会话"""
conversation_id = f"conv_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}"
if title is None:
title = f"新会话 {datetime.now().strftime('%m-%d %H:%M')}"
conversation = Conversation(
conversation_id=conversation_id,
user_id=user_id,
title=title,
gene_family=gene_family,
species=species,
system_prompt=system_prompt,
job_id=job_id,
input_dir=input_dir,
output_dir=output_dir,
working_dir=working_dir,
)
# 保存会话
await self._save_conversation(conversation)
# 更新用户索引
if user_id not in self._user_index:
self._user_index[user_id] = []
self._user_index[user_id].insert(0, conversation_id)
await self._save_user_index()
print(f"[ConversationManager] Created: {conversation_id} for user {user_id}")
return conversation
async def _save_conversation(self, conversation: Conversation):
"""保存会话到文件"""
file_path = self._get_conv_file(conversation.conversation_id)
async with self._lock:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(asdict(conversation), f, ensure_ascii=False, indent=2)
async def get_conversation(self, conversation_id: str) -> Optional[Conversation]:
"""获取会话详情"""
file_path = self._get_conv_file(conversation_id)
if not file_path.exists():
return None
try:
async with self._lock:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Conversation(**data)
except Exception as e:
print(f"[ConversationManager] Failed to load conversation: {e}")
return None
async def list_user_conversations(
self,
user_id: str,
status: str = "active",
limit: int = 20,
offset: int = 0,
) -> List[ConversationSummary]:
"""
列出用户的所有会话(类似ChatGPT左侧边栏)
Returns:
List[ConversationSummary]: 会话摘要列表,按更新时间倒序
"""
conversation_ids = self._user_index.get(user_id, [])
summaries = []
for conv_id in conversation_ids:
conv = await self.get_conversation(conv_id)
if conv and conv.status == status:
# 获取最后一条消息
last_msg = ""
if conv.messages:
last_msg_content = conv.messages[-1].get('content', '')
last_msg = last_msg_content[:50] + "..." if len(last_msg_content) > 50 else last_msg_content
summaries.append(ConversationSummary(
conversation_id=conv.conversation_id,
title=conv.title,
last_message=last_msg,
updated_at=conv.updated_at,
message_count=conv.message_count,
gene_family=conv.gene_family,
job_id=conv.job_id,
input_dir=conv.input_dir,
output_dir=conv.output_dir,
working_dir=conv.working_dir,
))
# 按更新时间倒序
summaries.sort(key=lambda x: x.updated_at, reverse=True)
return summaries[offset:offset + limit]
async def add_message(
self,
conversation_id: str,
role: str,
content: str,
metadata: dict = None,
) -> bool:
"""添加消息到会话"""
conv = await self.get_conversation(conversation_id)
if not conv:
return False
message = {
"role": role,
"content": content,
"timestamp": datetime.now().isoformat(),
"metadata": metadata or {},
}
conv.messages.append(message)
conv.message_count += 1
conv.updated_at = datetime.now().isoformat()
# 自动更新标题(第一条用户消息)
if conv.message_count == 1 and role == "user":
conv.title = content[:30] + "..." if len(content) > 30 else content
await self._save_conversation(conv)
return True
await self._save_conversation(conv)
return True
async def get_messages(
self,
conversation_id: str,
limit: int = 100,
offset: int = 0,
) -> List[dict]:
"""获取会话消息历史"""
conv = await self.get_conversation(conversation_id)
if not conv:
return []
return conv.messages[offset:offset + limit]
async def update_title(self, conversation_id: str, title: str) -> bool:
"""更新会话标题"""
conv = await self.get_conversation(conversation_id)
if not conv:
return False
conv.title = title
await self._save_conversation(conv)
return True
async def delete_conversation(self, conversation_id: str) -> bool:
"""删除会话"""
conv = await self.get_conversation(conversation_id)
if not conv:
return False
conv.status = "deleted"
await self._save_conversation(conv)
# 从用户索引中移除
if conv.user_id in self._user_index:
if conversation_id in self._user_index[conv.user_id]:
self._user_index[conv.user_id].remove(conversation_id)
await self._save_user_index()
return True
async def archive_conversation(self, conversation_id: str) -> bool:
"""归档会话"""
conv = await self.get_conversation(conversation_id)
if not conv:
return False
conv.status = "archived"
await self._save_conversation(conv)
return True
async def link_to_job(self, conversation_id: str, job_id: str) -> bool:
"""关联会话到分析任务"""
conv = await self.get_conversation(conversation_id)
if not conv:
return False
conv.job_id = job_id
await self._save_conversation(conv)
return True
+151
View File
@@ -0,0 +1,151 @@
from abc import abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from gfa_core.mas.memory.base import BaseMemory, MemoryEntry, SearchResult
@dataclass
class KnowledgeEntry:
entry_id: str
content: str
embedding: list[float] | None = None
source: str | None = None
category: str | None = None
tags: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
class KnowledgeMemory(BaseMemory):
def __init__(self, embedding_dim: int = 1536):
self._embedding_dim = embedding_dim
self._entries: dict[str, MemoryEntry[KnowledgeEntry]] = {}
self._embeddings: dict[str, list[float]] = {}
async def store(self, key: str, value: Any, ttl: int | None = None) -> None:
if isinstance(value, KnowledgeEntry):
entry = value
else:
entry = KnowledgeEntry(entry_id=key, **value)
memory_entry = MemoryEntry(key=key, value=entry)
self._entries[key] = memory_entry
if entry.embedding:
self._embeddings[key] = entry.embedding
async def retrieve(self, key: str) -> Any | None:
entry = self._entries.get(key)
return entry.value if entry else None
async def delete(self, key: str) -> bool:
if key in self._entries:
del self._entries[key]
self._embeddings.pop(key, None)
return True
return False
async def exists(self, key: str) -> bool:
return key in self._entries
async def clear(self) -> None:
self._entries.clear()
self._embeddings.clear()
async def search(
self,
query_embedding: list[float],
top_k: int = 10,
threshold: float = 0.0,
) -> list[SearchResult]:
if not self._embeddings:
return []
scores = []
for key, embedding in self._embeddings.items():
score = self._cosine_similarity(query_embedding, embedding)
if score >= threshold:
scores.append((key, score))
scores.sort(key=lambda x: x[1], reverse=True)
top_results = scores[:top_k]
results = []
for key, score in top_results:
entry = self._entries.get(key)
if entry:
results.append(SearchResult(
key=key,
value=entry.value,
score=score,
metadata=entry.metadata,
))
return results
def _cosine_similarity(self, a: list[float], b: list[float]) -> float:
if len(a) != len(b):
return 0.0
dot_product = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(x * x for x in b) ** 0.5
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
async def search_by_tags(self, tags: list[str], top_k: int = 10) -> list[SearchResult]:
results = []
for key, entry in self._entries.items():
if entry.value.tags and any(tag in entry.value.tags for tag in tags):
results.append(SearchResult(
key=key,
value=entry.value,
score=1.0,
metadata=entry.metadata,
))
if len(results) >= top_k:
break
return results
async def search_by_category(self, category: str, top_k: int = 10) -> list[SearchResult]:
results = []
for key, entry in self._entries.items():
if entry.value.category == category:
results.append(SearchResult(
key=key,
value=entry.value,
score=1.0,
metadata=entry.metadata,
))
if len(results) >= top_k:
break
return results
async def add_entry(
self,
entry_id: str,
content: str,
embedding: list[float] | None = None,
source: str | None = None,
category: str | None = None,
tags: list[str] | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
entry = KnowledgeEntry(
entry_id=entry_id,
content=content,
embedding=embedding,
source=source,
category=category,
tags=tags or [],
metadata=metadata or {},
)
await self.store(entry_id, entry)
+154
View File
@@ -0,0 +1,154 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from gfa_core.mas.memory.base import SearchResult
from gfa_core.mas.memory.case import AnalysisCase, CaseMemory
from gfa_core.mas.memory.knowledge import KnowledgeEntry, KnowledgeMemory
from gfa_core.mas.memory.session import SessionData, SessionMemory
@dataclass
class MemoryStats:
session_count: int = 0
case_count: int = 0
knowledge_count: int = 0
last_updated: datetime = field(default_factory=datetime.now)
class MemoryManager:
def __init__(
self,
session_memory: SessionMemory | None = None,
case_memory: CaseMemory | None = None,
knowledge_memory: KnowledgeMemory | None = None,
):
self.session = session_memory or SessionMemory()
self.case = case_memory or CaseMemory()
self.knowledge = knowledge_memory or KnowledgeMemory()
async def create_session(self, session_id: str, initial_state: dict[str, Any] | None = None) -> SessionData:
session = SessionData(
session_id=session_id,
state=initial_state or {},
)
await self.session.store(session_id, session)
return session
async def get_session(self, session_id: str) -> SessionData | None:
return await self.session.retrieve(session_id)
async def add_message_to_session(self, session_id: str, role: str, content: str) -> None:
await self.session.add_message(session_id, {"role": role, "content": content})
async def update_session_state(self, session_id: str, state: dict[str, Any]) -> None:
await self.session.update_state(session_id, state)
async def create_case(
self,
case_id: str,
user_id: str | None = None,
gene_family: str | None = None,
species: str | None = None,
workflow: list[dict] | None = None,
) -> AnalysisCase:
case = AnalysisCase(
case_id=case_id,
user_id=user_id,
gene_family=gene_family,
species=species,
workflow=workflow or [],
)
await self.case.store(case_id, case)
return case
async def get_case(self, case_id: str) -> AnalysisCase | None:
return await self.case.retrieve(case_id)
async def update_case_results(self, case_id: str, results: dict[str, Any]) -> None:
case = await self.case.retrieve(case_id)
if case:
case.results.update(results)
await self.case.store(case_id, case)
async def complete_case(self, case_id: str) -> None:
await self.case.update_status(case_id, "completed")
async def add_case_feedback(self, case_id: str, feedback: dict[str, Any]) -> None:
await self.case.add_feedback(case_id, feedback)
async def add_knowledge(
self,
entry_id: str,
content: str,
embedding: list[float] | None = None,
source: str | None = None,
category: str | None = None,
tags: list[str] | None = None,
) -> None:
await self.knowledge.add_entry(
entry_id=entry_id,
content=content,
embedding=embedding,
source=source,
category=category,
tags=tags,
)
async def search_knowledge(
self,
query_embedding: list[float],
top_k: int = 10,
) -> list[SearchResult]:
return await self.knowledge.search(query_embedding, top_k)
async def search_knowledge_by_tags(self, tags: list[str], top_k: int = 10) -> list[SearchResult]:
return await self.knowledge.search_by_tags(tags, top_k)
async def extract_case_to_knowledge(self, case_id: str) -> KnowledgeEntry | None:
case = await self.case.retrieve(case_id)
if not case or case.status != "completed":
return None
entry_id = f"case_{case_id}"
content = self._extract_knowledge_content(case)
entry = KnowledgeEntry(
entry_id=entry_id,
content=content,
source=f"case:{case_id}",
category="workflow_template",
tags=[case.gene_family, case.species] if case.gene_family and case.species else [],
metadata={
"gene_family": case.gene_family,
"species": case.species,
"workflow_steps": len(case.workflow),
},
)
await self.knowledge.store(entry_id, entry)
return entry
def _extract_knowledge_content(self, case: AnalysisCase) -> str:
parts = []
if case.gene_family:
parts.append(f"Gene Family: {case.gene_family}")
if case.species:
parts.append(f"Species: {case.species}")
if case.workflow:
parts.append("Workflow:")
for i, step in enumerate(case.workflow, 1):
parts.append(f" {i}. {step.get('tool', 'unknown')}: {step.get('description', '')}")
return "\n".join(parts)
async def get_stats(self) -> MemoryStats:
return MemoryStats(
session_count=len(self.session._sessions),
case_count=len(await self.case.list_by_status("completed")),
knowledge_count=len(self.knowledge._entries),
)
async def clear_all(self) -> None:
await self.session.clear()
await self.case.clear()
await self.knowledge.clear()
+142
View File
@@ -0,0 +1,142 @@
"""
持久化记忆存储 - 将记忆保存到文件
"""
import json
import asyncio
from pathlib import Path
from datetime import datetime
from typing import Any
from dataclasses import asdict
from gfa_core.mas.memory.base import BaseMemory, MemoryEntry
class PersistentMemory(BaseMemory):
"""基于文件的持久化记忆存储"""
def __init__(self, storage_dir: Path, max_entries: int = 10000):
self.storage_dir = Path(storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
self.max_entries = max_entries
self._lock = asyncio.Lock()
def _get_file_path(self, key: str) -> Path:
"""获取存储文件路径"""
# 使用key的前2个字符作为子目录,避免单目录文件过多
subdir = key[:2] if len(key) >= 2 else "xx"
dir_path = self.storage_dir / subdir
dir_path.mkdir(exist_ok=True)
return dir_path / f"{key}.json"
def _serialize(self, value: Any) -> dict:
"""序列化数据"""
if hasattr(value, '__dataclass_fields__'):
return {
"_type": "dataclass",
"_class": value.__class__.__name__,
"data": asdict(value)
}
return {"_type": "raw", "data": value}
def _deserialize(self, data: dict) -> Any:
"""反序列化数据"""
from gfa_core.mas.memory.session import SessionData
from gfa_core.mas.memory.case import AnalysisCase
from gfa_core.mas.memory.knowledge import KnowledgeEntry
if data.get("_type") == "dataclass":
class_name = data.get("_class")
class_map = {
"SessionData": SessionData,
"AnalysisCase": AnalysisCase,
"KnowledgeEntry": KnowledgeEntry,
}
cls = class_map.get(class_name)
if cls:
return cls(**data["data"])
return data.get("data")
async def store(self, key: str, value: Any, ttl: int | None = None) -> None:
"""存储数据到文件"""
async with self._lock:
file_path = self._get_file_path(key)
entry = MemoryEntry(
key=key,
value=self._serialize(value),
ttl=ttl,
)
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
json.dump({
"key": entry.key,
"value": entry.value,
"ttl": entry.ttl,
"created_at": entry.created_at.isoformat(),
}, f, ensure_ascii=False, indent=2)
async def retrieve(self, key: str) -> Any | None:
"""从文件读取数据"""
async with self._lock:
file_path = self._get_file_path(key)
if not file_path.exists():
return None
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 检查TTL
if data.get("ttl"):
created_at = datetime.fromisoformat(data["created_at"])
from datetime import timedelta
if datetime.now() > created_at + timedelta(seconds=data["ttl"]):
file_path.unlink() # 删除过期文件
return None
return self._deserialize(data["value"])
except (json.JSONDecodeError, KeyError, ValueError):
return None
async def delete(self, key: str) -> bool:
"""删除数据"""
async with self._lock:
file_path = self._get_file_path(key)
if file_path.exists():
file_path.unlink()
return True
return False
async def exists(self, key: str) -> bool:
"""检查数据是否存在"""
file_path = self._get_file_path(key)
if not file_path.exists():
return False
# 检查是否过期
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if data.get("ttl"):
created_at = datetime.fromisoformat(data["created_at"])
from datetime import timedelta
if datetime.now() > created_at + timedelta(seconds=data["ttl"]):
file_path.unlink()
return False
return True
except:
return False
async def clear(self) -> None:
"""清空所有数据"""
async with self._lock:
import shutil
if self.storage_dir.exists():
shutil.rmtree(self.storage_dir)
self.storage_dir.mkdir(parents=True, exist_ok=True)
+144
View File
@@ -0,0 +1,144 @@
import asyncio
from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from gfa_core.mas.memory.base import BaseMemory, MemoryEntry
from gfa_core.mas.memory.persistent import PersistentMemory
@dataclass
class SessionData:
session_id: str
messages: list[dict] = field(default_factory=list)
state: dict[str, Any] = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
class SessionMemory(BaseMemory):
"""SessionMemory with persistent backend"""
def __init__(
self,
max_sessions: int = 1000,
default_ttl: int = 3600,
persistent_storage: Optional[PersistentMemory] = None
):
self._sessions: OrderedDict[str, MemoryEntry[SessionData]] = OrderedDict()
self._max_sessions = max_sessions
self._default_ttl = default_ttl
self._lock = asyncio.Lock()
# 持久化后端
self._persistent = persistent_storage
if self._persistent:
print(f"[SessionMemory] Persistent storage enabled: {self._persistent.storage_dir}")
async def store(self, key: str, value: Any, ttl: int | None = None) -> None:
async with self._lock:
if len(self._sessions) >= self._max_sessions and key not in self._sessions:
self._sessions.popitem(last=False)
if isinstance(value, SessionData):
session_data = value
else:
session_data = SessionData(session_id=key, state=value)
entry = MemoryEntry(
key=key,
value=session_data,
ttl=ttl or self._default_ttl,
)
self._sessions[key] = entry
self._sessions.move_to_end(key)
# 持久化存储
if self._persistent:
await self._persistent.store(key, session_data, ttl)
async def retrieve(self, key: str) -> Any | None:
async with self._lock:
# 先查内存
entry = self._sessions.get(key)
# 内存未命中,查持久化
if entry is None and self._persistent:
persistent_data = await self._persistent.retrieve(key)
if persistent_data:
self._sessions[key] = MemoryEntry(
key=key,
value=persistent_data,
ttl=self._default_ttl
)
entry = self._sessions[key]
if entry is None:
return None
if entry.ttl and datetime.now() > entry.created_at + timedelta(seconds=entry.ttl):
del self._sessions[key]
if self._persistent:
await self._persistent.delete(key)
return None
self._sessions.move_to_end(key)
return entry.value
async def delete(self, key: str) -> bool:
async with self._lock:
deleted = False
if key in self._sessions:
del self._sessions[key]
deleted = True
if self._persistent:
persistent_deleted = await self._persistent.delete(key)
deleted = deleted or persistent_deleted
return deleted
async def exists(self, key: str) -> bool:
async with self._lock:
# 先查内存
entry = self._sessions.get(key)
if entry:
if entry.ttl and datetime.now() > entry.created_at + timedelta(seconds=entry.ttl):
del self._sessions[key]
if self._persistent:
await self._persistent.delete(key)
return False
return True
# 查持久化
if self._persistent:
return await self._persistent.exists(key)
return False
async def clear(self) -> None:
async with self._lock:
self._sessions.clear()
if self._persistent:
await self._persistent.clear()
async def add_message(self, session_id: str, message: dict) -> None:
session = await self.retrieve(session_id)
if session is None:
session = SessionData(session_id=session_id)
session.messages.append(message)
session.updated_at = datetime.now()
await self.store(session_id, session)
async def get_messages(self, session_id: str) -> list[dict]:
session = await self.retrieve(session_id)
return session.messages if session else []
async def update_state(self, session_id: str, state: dict[str, Any]) -> None:
session = await self.retrieve(session_id)
if session is None:
session = SessionData(session_id=session_id)
session.state.update(state)
session.updated_at = datetime.now()
await self.store(session_id, session)