OSVauco/opax-mcp/persistence/firestore_store.py

87 lines
3.9 KiB
Python

"""
Firestore-backed implementation of the MemoryStore interface.
"""
from typing import List, Dict, Any, Optional
from datetime import datetime, timezone
from google.cloud import firestore
from contracts.common import EmmaConversation, MemoryRecord, AuditEvent
from .memory_store import MemoryStore
class FirestoreMemoryStore(MemoryStore):
"""
Implements the MemoryStore interface using Google Cloud Firestore.
"""
def __init__(self, project_id: str, client: Optional[firestore.AsyncClient] = None):
self._project_id = project_id
# Client can be injected for testing, otherwise it will be created on demand.
self._client = client
def _get_client(self) -> firestore.AsyncClient:
if not self._client:
self._client = firestore.AsyncClient(project=self._project_id)
return self._client
async def create_conversation(self, owner_id: str, workspace_id: str, created_by: str) -> EmmaConversation:
db = self._get_client()
convo = EmmaConversation(owner_id=owner_id, workspace_id=workspace_id, created_by=created_by)
doc_ref = db.collection("emma_conversations").document(convo.conversation_id)
await doc_ref.set(convo.model_dump(mode='json'))
return convo
async def get_conversation(self, conversation_id: str, owner_id: str) -> Optional[EmmaConversation]:
db = self._get_client()
doc_ref = db.collection("emma_conversations").document(conversation_id)
doc = await doc_ref.get()
if not doc.exists:
return None
convo = EmmaConversation(**doc.to_dict())
# Security: Enforce ownership
if convo.owner_id != owner_id:
return None # Or raise an exception
return convo
async def append_message(self, conversation_id: str, message: Dict[str, Any]) -> None:
db = self._get_client()
# Note: message_id should be auto-generated by Firestore for ordering
messages_ref = db.collection("emma_conversations").document(conversation_id).collection("messages")
await messages_ref.add(message)
# Update last_updated_at on parent conversation document
convo_ref = db.collection("emma_conversations").document(conversation_id)
await convo_ref.update({"last_updated_at": datetime.now(timezone.utc)})
async def list_messages(self, conversation_id: str, limit: int = 50) -> List[Dict[str, Any]]:
db = self._get_client()
messages_ref = db.collection("emma_conversations").document(conversation_id).collection("messages")
docs = messages_ref.order_by("created_at", direction=firestore.Query.DESCENDING).limit(limit).stream()
return [doc.to_dict() async for doc in docs][::-1] # Reverse to get chronological order
async def record_audit_event(self, event: AuditEvent) -> str:
db = self._get_client()
doc_ref = db.collection("emma_audit_events").document(event.event_id)
# Use create() to ensure the document does not already exist, guaranteeing append-only.
await doc_ref.create(event.model_dump(mode='json'))
return event.event_id
# --- Placeholder methods not fully implemented in Phase 2B ---
async def save_conversation_summary(self, conversation_id: str, summary: str) -> None:
# In a real implementation, this would update a summary field
pass
async def store_memory(self, record: MemoryRecord) -> None:
# This would write to the emma_memories collection
pass
async def recall_memory(self, embedding: List[float], owner_id: str, scopes: List[str]) -> List[MemoryRecord]:
# This would perform a query against a vector database, which is out of scope.
return []
async def get_profile(self, owner_id: str) -> Optional[Dict[str, Any]]:
pass
async def update_profile(self, owner_id: str, profile_data: Dict[str, Any]) -> None:
pass