"""Thread-safe per-job blackboard for the Agent pipeline.""" import copy import json import os import threading from typing import Any, Dict, Iterable, Optional _COLLECTION_KEYS = {"sheets", "clusters", "findings", "decisions", "rfis", "suppressed"} _MAPPING_KEYS = {"sheet_index", "jurisdiction", "object_graph"} _MEMORY_KEYS = _COLLECTION_KEYS | _MAPPING_KEYS class ProjectMemory: """Owns intermediate Agent-mode state and optional debug snapshots.""" def __init__(self, artifact_dir: Optional[str] = None) -> None: self.artifact_dir = artifact_dir self._lock = threading.RLock() self._data: Dict[str, Any] = { **{key: [] for key in _COLLECTION_KEYS}, **{key: {} for key in _MAPPING_KEYS}, } if artifact_dir: os.makedirs(artifact_dir, exist_ok=True) def replace(self, key: str, value: Any) -> None: """Replace one named memory section.""" self._validate_key(key) with self._lock: self._data[key] = copy.deepcopy(value) def append(self, key: str, value: Dict[str, Any]) -> None: """Append one artifact to a list-backed memory section.""" if key not in _COLLECTION_KEYS: raise KeyError(f"{key!r} is not an appendable memory section") with self._lock: self._data[key].append(copy.deepcopy(value)) def extend(self, key: str, values: Iterable[Dict[str, Any]]) -> None: """Append several artifacts under one lock.""" if key not in _COLLECTION_KEYS: raise KeyError(f"{key!r} is not an appendable memory section") with self._lock: self._data[key].extend(copy.deepcopy(list(values))) def snapshot(self) -> Dict[str, Any]: """Return a detached, JSON-serializable view of current state.""" with self._lock: return copy.deepcopy(self._data) def dump(self, filename: str = "memory.json") -> Optional[str]: """Persist a snapshot when this job has an artifact directory.""" if not self.artifact_dir: return None path = os.path.join(self.artifact_dir, filename) temp_path = f"{path}.tmp" with open(temp_path, "w", encoding="utf-8") as f: json.dump(self.snapshot(), f, indent=2) os.replace(temp_path, path) return path @staticmethod def _validate_key(key: str) -> None: if key not in _MEMORY_KEYS: raise KeyError(f"Unknown project memory section: {key!r}")