dmChatbotBackend / src /core /memory.py
github-actions
Auto deploy from GitHub
cb505ff
Raw History Blame Contribute Delete
4.7 kB
"""Governed proposal workflow for narrative patient memory."""
from __future__ import annotations
import datetime
import uuid
from dataclasses import asdict, dataclass
from typing import Any, Dict, Optional
from src.tools.patient_memory import save_patient_memory
import re
ALLOWED_CATEGORIES = {"glucose_history", "medications", "diet"}
MAX_VALUE_LENGTH = 2000
# Patterns that indicate forbidden memory content (system instructions, secrets, diagnostic claims)
FORBIDDEN_CONTENT_PATTERNS = [
r"ignore previous instructions",
r"system prompt",
r"api[_-]?key",
r"password\s*=",
r"secret\s*=",
r"diagnosis:\s*definitive",
]
@dataclass
class MemoryProposal:
proposal_id: str
patient_id: str
category: str
value: str
source: str
confidence: Optional[float]
status: str
created_at: str
expires_at: Optional[str] = None
session_id: Optional[str] = None
trace_id: Optional[str] = None
def as_dict(self) -> Dict[str, Any]:
return asdict(self)
class MemoryService:
"""Create and commit memory only through explicit policy decisions."""
def propose(
self,
*,
patient_id: str,
category: str,
value: str,
source: str,
confidence: Optional[float] = None,
ttl_seconds: Optional[int] = 86400 * 30, # 30 day default TTL
session_id: Optional[str] = None,
trace_id: Optional[str] = None,
) -> MemoryProposal:
if not patient_id:
raise ValueError("patient_id is required")
if category not in ALLOWED_CATEGORIES:
raise ValueError(f"unsupported memory category: {category}")
if not isinstance(value, str) or not value.strip():
raise ValueError("memory value must be non-empty text")
if len(value) > MAX_VALUE_LENGTH:
raise ValueError("memory value exceeds the maximum length")
if confidence is not None and not 0.0 <= confidence <= 1.0:
raise ValueError("confidence must be between 0.0 and 1.0")
# Validate against prompt injection, secrets, or raw diagnostic claims
normalized = value.lower()
for pattern in FORBIDDEN_CONTENT_PATTERNS:
if re.search(pattern, normalized):
raise ValueError(f"memory value contains forbidden content pattern: {pattern}")
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = (now + datetime.timedelta(seconds=ttl_seconds)).isoformat() if ttl_seconds else None
return MemoryProposal(
proposal_id=str(uuid.uuid4()),
patient_id=patient_id,
category=category,
value=value.strip(),
source=source,
confidence=confidence,
status="pending_consent",
created_at=now.isoformat(),
expires_at=expires_at,
session_id=session_id,
trace_id=trace_id,
)
def commit(self, proposal: MemoryProposal, *, consent_granted: bool) -> Dict[str, Any]:
if proposal.status != "pending_consent":
raise ValueError(f"proposal is not pending consent: {proposal.proposal_id}")
if not consent_granted:
proposal.status = "rejected"
return {"proposal_id": proposal.proposal_id, "status": proposal.status}
result = save_patient_memory.invoke({proposal.category: proposal.value, "patient_id": proposal.patient_id})
proposal.status = "committed"
return {
"proposal_id": proposal.proposal_id,
"status": proposal.status,
"result": result,
"source": proposal.source,
"committed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
@staticmethod
def resolve_conflicts(proposals: list[Dict[str, Any] | MemoryProposal]) -> list[Dict[str, Any]]:
"""
Deterministically resolve memory proposal conflicts by category and timestamp recency.
Retains the latest proposal for each category.
"""
latest_by_category: Dict[str, Dict[str, Any]] = {}
for p in proposals:
p_dict = p.as_dict() if isinstance(p, MemoryProposal) else dict(p)
cat = p_dict.get("category")
if not cat:
continue
existing = latest_by_category.get(cat)
if existing is None or p_dict.get("created_at", "") >= existing.get("created_at", ""):
latest_by_category[cat] = p_dict
return list(latest_by_category.values())