From 9ccc61c851e93398be4b6c0e21cc32668f340a0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:49:04 +0000 Subject: [PATCH 1/5] Initial plan From 4351e9b701af0db8f6a4b9baa7758463dcaa3351 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:55:15 +0000 Subject: [PATCH 2/5] Implement schemas and core Story/Scene operations Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .../src/monitor_data/db/__init__.py | 5 +- .../data-layer/src/monitor_data/db/mongodb.py | 194 ++++++++ .../src/monitor_data/middleware/auth.py | 6 +- .../src/monitor_data/schemas/scenes.py | 141 ++++++ .../src/monitor_data/schemas/stories.py | 103 +++++ .../src/monitor_data/tools/mongodb_tools.py | 434 ++++++++++++++++++ .../src/monitor_data/tools/neo4j_tools.py | 334 ++++++++++++++ 7 files changed, 1212 insertions(+), 5 deletions(-) create mode 100644 packages/data-layer/src/monitor_data/db/mongodb.py create mode 100644 packages/data-layer/src/monitor_data/schemas/scenes.py create mode 100644 packages/data-layer/src/monitor_data/schemas/stories.py create mode 100644 packages/data-layer/src/monitor_data/tools/mongodb_tools.py diff --git a/packages/data-layer/src/monitor_data/db/__init__.py b/packages/data-layer/src/monitor_data/db/__init__.py index ab0c83e..aab5c84 100644 --- a/packages/data-layer/src/monitor_data/db/__init__.py +++ b/packages/data-layer/src/monitor_data/db/__init__.py @@ -15,15 +15,16 @@ """ from monitor_data.db.neo4j import Neo4jClient, get_neo4j_client +from monitor_data.db.mongodb import MongoDBClient, get_mongodb_client -# from monitor_data.db.mongodb import MongoDBClient # from monitor_data.db.qdrant import QdrantClient # from monitor_data.db.minio import MinIOClient __all__ = [ "Neo4jClient", "get_neo4j_client", - # "MongoDBClient", + "MongoDBClient", + "get_mongodb_client", # "QdrantClient", # "MinIOClient", ] diff --git a/packages/data-layer/src/monitor_data/db/mongodb.py b/packages/data-layer/src/monitor_data/db/mongodb.py new file mode 100644 index 0000000..2382b43 --- /dev/null +++ b/packages/data-layer/src/monitor_data/db/mongodb.py @@ -0,0 +1,194 @@ +""" +MongoDB client for MONITOR Data Layer. + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries only (pymongo) +CALLED BY: mongodb_tools.py + +This client provides a thin wrapper around pymongo for: +- Scenes: Narrative episodes and their turns +- ProposedChanges: Staging area for canonization +- Memories: Agent and character memories +- Documents: Ingested source documents + +Collections: +- scenes: Scene documents with embedded turns +- proposed_changes: Proposed changes awaiting canonization +- memories: Character and agent memories +- documents: Source documents and metadata +- snippets: Document snippets with embeddings +""" + +import os +from typing import Optional, Dict, Any, List +from pymongo import MongoClient, ASCENDING, DESCENDING +from pymongo.database import Database +from pymongo.collection import Collection + + +class MongoDBClient: + """ + MongoDB client for MONITOR narrative and document storage. + + Thread-safe singleton client for MongoDB operations. + Manages connection lifecycle and provides collection access. + """ + + _instance: Optional["MongoDBClient"] = None + _client: Optional[MongoClient] = None + _db: Optional[Database] = None + + def __init__( + self, + uri: Optional[str] = None, + database: Optional[str] = None, + ): + """ + Initialize MongoDB client. + + Args: + uri: MongoDB connection URI (default: from MONGODB_URI env var) + database: Database name (default: from MONGODB_DATABASE env var or "monitor") + """ + self.uri = uri or os.getenv("MONGODB_URI", "mongodb://localhost:27017") + self.database_name = database or os.getenv("MONGODB_DATABASE", "monitor") + self._client = None + self._db = None + + def connect(self) -> None: + """ + Establish connection to MongoDB. + + Creates indexes for all collections on first connection. + """ + if self._client is None: + self._client = MongoClient(self.uri) + self._db = self._client[self.database_name] + self._create_indexes() + + def close(self) -> None: + """Close MongoDB connection.""" + if self._client: + self._client.close() + self._client = None + self._db = None + + def verify_connectivity(self) -> bool: + """ + Verify MongoDB connection is working. + + Returns: + True if connection is healthy, False otherwise + """ + try: + if self._client is None: + self.connect() + # Ping the server + self._client.admin.command("ping") + return True + except Exception: + return False + + def get_database(self) -> Database: + """ + Get the MongoDB database object. + + Returns: + pymongo Database object + + Raises: + RuntimeError: If not connected + """ + if self._db is None: + raise RuntimeError("MongoDB client not connected. Call connect() first.") + return self._db + + def get_collection(self, name: str) -> Collection: + """ + Get a collection by name. + + Args: + name: Collection name + + Returns: + pymongo Collection object + """ + db = self.get_database() + return db[name] + + def _create_indexes(self) -> None: + """ + Create indexes for all collections. + + Called automatically on first connection. + """ + if self._db is None: + return + + # Scenes collection indexes + scenes = self._db["scenes"] + scenes.create_index([("scene_id", ASCENDING)], unique=True) + scenes.create_index([("story_id", ASCENDING), ("order", ASCENDING)]) + scenes.create_index([("status", ASCENDING)]) + scenes.create_index([("created_at", DESCENDING)]) + + # Proposed changes collection indexes + proposed_changes = self._db["proposed_changes"] + proposed_changes.create_index([("proposal_id", ASCENDING)], unique=True) + proposed_changes.create_index([("scene_id", ASCENDING), ("status", ASCENDING)]) + proposed_changes.create_index([("status", ASCENDING)]) + + # Memories collection indexes + memories = self._db["memories"] + memories.create_index([("memory_id", ASCENDING)], unique=True) + memories.create_index([("entity_id", ASCENDING)]) + memories.create_index([("created_at", DESCENDING)]) + + # Documents collection indexes + documents = self._db["documents"] + documents.create_index([("doc_id", ASCENDING)], unique=True) + documents.create_index([("universe_id", ASCENDING)]) + documents.create_index([("status", ASCENDING)]) + + # Snippets collection indexes + snippets = self._db["snippets"] + snippets.create_index([("snippet_id", ASCENDING)], unique=True) + snippets.create_index([("doc_id", ASCENDING)]) + + +# ============================================================================= +# SINGLETON ACCESS +# ============================================================================= + +_mongodb_client_instance: Optional[MongoDBClient] = None + + +def get_mongodb_client() -> MongoDBClient: + """ + Get or create the singleton MongoDB client. + + Returns: + MongoDBClient instance + + Thread-safe singleton pattern for database connections. + """ + global _mongodb_client_instance + + if _mongodb_client_instance is None: + _mongodb_client_instance = MongoDBClient() + _mongodb_client_instance.connect() + + return _mongodb_client_instance + + +def reset_mongodb_client() -> None: + """ + Reset the MongoDB client singleton. + + Used for testing to ensure clean state between tests. + """ + global _mongodb_client_instance + + if _mongodb_client_instance is not None: + _mongodb_client_instance.close() + _mongodb_client_instance = None diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index 6ea5157..5426818 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -89,14 +89,14 @@ # ========================================================================= # MONGODB OPERATIONS - Scenes # ========================================================================= - "mongodb_create_scene": ["Orchestrator"], + "mongodb_create_scene": ["CanonKeeper", "Narrator"], "mongodb_get_scene": ["*"], - "mongodb_update_scene": ["Orchestrator"], + "mongodb_update_scene": ["CanonKeeper", "Narrator"], "mongodb_list_scenes": ["*"], # ========================================================================= # MONGODB OPERATIONS - Turns # ========================================================================= - "mongodb_append_turn": ["Narrator", "Orchestrator"], + "mongodb_append_turn": ["*"], "mongodb_get_turns": ["*"], "mongodb_undo_turn": ["Orchestrator"], # ========================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/scenes.py b/packages/data-layer/src/monitor_data/schemas/scenes.py new file mode 100644 index 0000000..8d7b0f8 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/scenes.py @@ -0,0 +1,141 @@ +""" +Pydantic schemas for Scene and Turn operations (MongoDB). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: mongodb_tools.py + +These schemas define the data contracts for Scene and Turn CRUD operations. +Scenes are narrative episodes stored in MongoDB for flexibility. +Turns are individual exchanges within scenes. +""" + +from datetime import datetime, timezone +from typing import Optional, List +from uuid import UUID + +from pydantic import BaseModel, Field, field_validator + +from monitor_data.schemas.base import SceneStatus, Speaker + + +# ============================================================================= +# TURN SCHEMAS +# ============================================================================= + + +class TurnCreate(BaseModel): + """Request to create a Turn (append to scene).""" + + speaker: Speaker + entity_id: Optional[UUID] = Field( + None, description="Entity ID if speaker is entity" + ) + text: str = Field(min_length=1, max_length=10000) + + @field_validator("entity_id") + @classmethod + def validate_entity_speaker(cls, v: Optional[UUID], info) -> Optional[UUID]: + """Validate that entity_id is provided when speaker is entity.""" + if info.data.get("speaker") == Speaker.ENTITY and v is None: + raise ValueError("entity_id required when speaker is entity") + return v + + +class TurnResponse(BaseModel): + """Response with Turn data.""" + + turn_id: UUID + speaker: Speaker + entity_id: Optional[UUID] = None + text: str + timestamp: datetime + resolution_ref: Optional[UUID] = Field( + None, description="Reference to resolution document" + ) + + model_config = {"from_attributes": True} + + +# ============================================================================= +# SCENE SCHEMAS +# ============================================================================= + + +class SceneCreate(BaseModel): + """Request to create a Scene.""" + + story_id: UUID + universe_id: UUID + title: str = Field(min_length=1, max_length=200) + purpose: str = Field( + default="", max_length=1000, description="Scene purpose or goal" + ) + order: Optional[int] = Field(None, ge=0, description="Scene order in story") + location_ref: Optional[UUID] = Field( + None, description="EntityInstance ID for location" + ) + participating_entities: List[UUID] = Field( + default_factory=list, description="EntityInstance IDs of participants" + ) + status: SceneStatus = Field(default=SceneStatus.ACTIVE) + + +class SceneUpdate(BaseModel): + """Request to update a Scene. + + Enforces valid status transitions. + """ + + title: Optional[str] = Field(None, min_length=1, max_length=200) + purpose: Optional[str] = Field(None, max_length=1000) + status: Optional[SceneStatus] = None + summary: Optional[str] = Field(None, max_length=5000, description="Scene summary") + + +class SceneResponse(BaseModel): + """Response with Scene data.""" + + scene_id: UUID + story_id: UUID + universe_id: UUID + title: str + purpose: str + status: SceneStatus + order: Optional[int] = None + location_ref: Optional[UUID] = None + participating_entities: List[UUID] = Field(default_factory=list) + turns: List[TurnResponse] = Field(default_factory=list) + proposed_changes: List[UUID] = Field(default_factory=list) + canonical_outcomes: List[UUID] = Field(default_factory=list) + summary: str = Field(default="") + created_at: datetime + updated_at: datetime + completed_at: Optional[datetime] = None + + model_config = {"from_attributes": True} + + +class SceneFilter(BaseModel): + """Filter parameters for listing scenes.""" + + story_id: Optional[UUID] = None + universe_id: Optional[UUID] = None + status: Optional[SceneStatus] = None + limit: int = Field(default=50, ge=1, le=1000) + offset: int = Field(default=0, ge=0) + sort_by: str = Field( + default="created_at", description="Field to sort by: created_at, order" + ) + sort_order: str = Field( + default="desc", description="Sort order: asc, desc", pattern="^(asc|desc)$" + ) + + +class SceneListResponse(BaseModel): + """Response with list of scenes and pagination info.""" + + scenes: List[SceneResponse] + total: int + limit: int + offset: int diff --git a/packages/data-layer/src/monitor_data/schemas/stories.py b/packages/data-layer/src/monitor_data/schemas/stories.py new file mode 100644 index 0000000..8ef45af --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/stories.py @@ -0,0 +1,103 @@ +""" +Pydantic schemas for Story operations (Neo4j). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: neo4j_tools.py + +These schemas define the data contracts for Story CRUD operations. +Stories are canonical containers for narrative campaigns/arcs/episodes. +""" + +from datetime import datetime +from typing import Optional, List +from uuid import UUID + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import StoryType, StoryStatus + + +# ============================================================================= +# STORY SCHEMAS +# ============================================================================= + + +class StoryCreate(BaseModel): + """Request to create a Story.""" + + universe_id: UUID + title: str = Field(min_length=1, max_length=200) + story_type: StoryType = Field(default=StoryType.CAMPAIGN) + theme: str = Field(default="", max_length=500, description="Main theme or tone") + premise: str = Field( + default="", max_length=2000, description="Story premise or summary" + ) + status: StoryStatus = Field(default=StoryStatus.PLANNED) + start_time_ref: Optional[datetime] = Field( + None, description="In-universe start time" + ) + pc_ids: List[UUID] = Field( + default_factory=list, + description="Player character entity IDs (creates PARTICIPATES edges)", + ) + + +class StoryUpdate(BaseModel): + """Request to update a Story. + + Only mutable fields can be updated: title, theme, premise, status. + Structural fields require special operations. + """ + + title: Optional[str] = Field(None, min_length=1, max_length=200) + theme: Optional[str] = Field(None, max_length=500) + premise: Optional[str] = Field(None, max_length=2000) + status: Optional[StoryStatus] = None + + +class StoryResponse(BaseModel): + """Response with Story data.""" + + id: UUID + universe_id: UUID + title: str + story_type: StoryType + theme: str + premise: str + status: StoryStatus + start_time_ref: Optional[datetime] = None + end_time_ref: Optional[datetime] = None + created_at: datetime + completed_at: Optional[datetime] = None + scene_count: int = Field(default=0, description="Number of scenes in this story") + pc_ids: List[UUID] = Field( + default_factory=list, description="Player character entity IDs" + ) + + model_config = {"from_attributes": True} + + +class StoryFilter(BaseModel): + """Filter parameters for listing stories.""" + + universe_id: Optional[UUID] = None + story_type: Optional[StoryType] = None + status: Optional[StoryStatus] = None + limit: int = Field(default=50, ge=1, le=1000) + offset: int = Field(default=0, ge=0) + sort_by: str = Field( + default="created_at", description="Field to sort by: created_at, title" + ) + sort_order: str = Field( + default="desc", description="Sort order: asc, desc", pattern="^(asc|desc)$" + ) + + +class StoryListResponse(BaseModel): + """Response with list of stories and pagination info.""" + + stories: List[StoryResponse] + total: int + limit: int + offset: int diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py new file mode 100644 index 0000000..1d9cea4 --- /dev/null +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -0,0 +1,434 @@ +""" +MongoDB MCP Tools for MONITOR Data Layer. + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries and data-layer modules only +CALLED BY: Agents (Layer 2) via MCP protocol + +These tools expose MongoDB operations via the MCP server. +MongoDB stores narrative artifacts (scenes, turns) and proposals. +""" + +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from uuid import UUID, uuid4 + +from monitor_data.db.mongodb import get_mongodb_client +from monitor_data.db.neo4j import get_neo4j_client +from monitor_data.schemas.scenes import ( + SceneCreate, + SceneUpdate, + SceneResponse, + SceneFilter, + SceneListResponse, + TurnCreate, + TurnResponse, +) +from monitor_data.schemas.base import SceneStatus + + +# ============================================================================= +# SCENE OPERATIONS +# ============================================================================= + + +def mongodb_create_scene(params: SceneCreate) -> SceneResponse: + """ + Create a new Scene document in MongoDB. + + Authority: Orchestrator only + Use Case: DL-4 + + Args: + params: Scene creation parameters + + Returns: + SceneResponse with created scene data + + Raises: + ValueError: If story_id doesn't exist in Neo4j or universe_id is invalid + """ + mongo_client = get_mongodb_client() + neo4j_client = get_neo4j_client() + + # Verify story exists in Neo4j + verify_story_query = """ + MATCH (s:Story {id: $story_id}) + MATCH (u:Universe {id: $universe_id}) + RETURN s.id as story_id, u.id as universe_id + """ + result = neo4j_client.execute_read( + verify_story_query, + {"story_id": str(params.story_id), "universe_id": str(params.universe_id)}, + ) + if not result: + raise ValueError( + f"Story {params.story_id} or Universe {params.universe_id} not found" + ) + + # Verify participating entities if provided + if params.participating_entities: + entity_check_query = """ + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + RETURN e.id as id + """ + for entity_id in params.participating_entities: + result = neo4j_client.execute_read( + entity_check_query, {"entity_id": str(entity_id)} + ) + if not result: + raise ValueError(f"Entity {entity_id} not found") + + # Verify location_ref if provided + if params.location_ref: + location_check_query = """ + MATCH (e:EntityInstance {id: $location_id}) + RETURN e.id as id + """ + result = neo4j_client.execute_read( + location_check_query, {"location_id": str(params.location_ref)} + ) + if not result: + raise ValueError(f"Location entity {params.location_ref} not found") + + # Create scene in MongoDB + scene_id = uuid4() + created_at = datetime.now(timezone.utc) + + scene_doc = { + "scene_id": str(scene_id), + "story_id": str(params.story_id), + "universe_id": str(params.universe_id), + "title": params.title, + "purpose": params.purpose, + "status": params.status.value, + "order": params.order, + "location_ref": str(params.location_ref) if params.location_ref else None, + "participating_entities": [str(eid) for eid in params.participating_entities], + "turns": [], + "proposed_changes": [], + "canonical_outcomes": [], + "summary": "", + "created_at": created_at, + "updated_at": created_at, + "completed_at": None, + } + + scenes_collection = mongo_client.get_collection("scenes") + scenes_collection.insert_one(scene_doc) + + return SceneResponse( + scene_id=scene_id, + story_id=params.story_id, + universe_id=params.universe_id, + title=params.title, + purpose=params.purpose, + status=params.status, + order=params.order, + location_ref=params.location_ref, + participating_entities=params.participating_entities, + turns=[], + proposed_changes=[], + canonical_outcomes=[], + summary="", + created_at=created_at, + updated_at=created_at, + completed_at=None, + ) + + +def mongodb_get_scene(scene_id: UUID) -> Optional[SceneResponse]: + """ + Retrieve a Scene by ID with all turns. + + Authority: All agents + Use Case: DL-4 + + Args: + scene_id: UUID of the scene to retrieve + + Returns: + SceneResponse if found, None otherwise + """ + mongo_client = get_mongodb_client() + scenes_collection = mongo_client.get_collection("scenes") + + scene_doc = scenes_collection.find_one({"scene_id": str(scene_id)}) + if not scene_doc: + return None + + # Convert turns from dict to TurnResponse + turns = [] + for turn_dict in scene_doc.get("turns", []): + turns.append( + TurnResponse( + turn_id=UUID(turn_dict["turn_id"]), + speaker=turn_dict["speaker"], + entity_id=UUID(turn_dict["entity_id"]) if turn_dict.get("entity_id") else None, + text=turn_dict["text"], + timestamp=turn_dict["timestamp"], + resolution_ref=UUID(turn_dict["resolution_ref"]) + if turn_dict.get("resolution_ref") + else None, + ) + ) + + return SceneResponse( + scene_id=UUID(scene_doc["scene_id"]), + story_id=UUID(scene_doc["story_id"]), + universe_id=UUID(scene_doc["universe_id"]), + title=scene_doc["title"], + purpose=scene_doc["purpose"], + status=SceneStatus(scene_doc["status"]), + order=scene_doc.get("order"), + location_ref=UUID(scene_doc["location_ref"]) if scene_doc.get("location_ref") else None, + participating_entities=[UUID(eid) for eid in scene_doc.get("participating_entities", [])], + turns=turns, + proposed_changes=[UUID(pid) for pid in scene_doc.get("proposed_changes", [])], + canonical_outcomes=[UUID(cid) for cid in scene_doc.get("canonical_outcomes", [])], + summary=scene_doc.get("summary", ""), + created_at=scene_doc["created_at"], + updated_at=scene_doc["updated_at"], + completed_at=scene_doc.get("completed_at"), + ) + + +def mongodb_update_scene(scene_id: UUID, params: SceneUpdate) -> SceneResponse: + """ + Update a Scene's mutable fields with status transition enforcement. + + Authority: Orchestrator only + Use Case: DL-4 + + Valid status transitions: active → finalizing → completed + + Args: + scene_id: UUID of the scene to update + params: Fields to update + + Returns: + SceneResponse with updated scene data + + Raises: + ValueError: If scene doesn't exist or invalid status transition + """ + mongo_client = get_mongodb_client() + scenes_collection = mongo_client.get_collection("scenes") + + # Verify scene exists + scene_doc = scenes_collection.find_one({"scene_id": str(scene_id)}) + if not scene_doc: + raise ValueError(f"Scene {scene_id} not found") + + # Validate status transition if status is being updated + if params.status is not None: + current_status = SceneStatus(scene_doc["status"]) + new_status = params.status + + # Define valid transitions + valid_transitions = { + SceneStatus.ACTIVE: [SceneStatus.FINALIZING, SceneStatus.COMPLETED], + SceneStatus.FINALIZING: [SceneStatus.COMPLETED], + SceneStatus.COMPLETED: [], # No transitions from completed + } + + if new_status != current_status: + if new_status not in valid_transitions.get(current_status, []): + raise ValueError( + f"Invalid status transition from {current_status.value} to {new_status.value}. " + f"Valid transitions: {[s.value for s in valid_transitions.get(current_status, [])]}" + ) + + # Build update document + update_doc: Dict[str, Any] = {"updated_at": datetime.now(timezone.utc)} + + if params.title is not None: + update_doc["title"] = params.title + + if params.purpose is not None: + update_doc["purpose"] = params.purpose + + if params.status is not None: + update_doc["status"] = params.status.value + # If completing the scene, set completed_at + if params.status == SceneStatus.COMPLETED: + update_doc["completed_at"] = datetime.now(timezone.utc) + + if params.summary is not None: + update_doc["summary"] = params.summary + + # Update scene + scenes_collection.update_one({"scene_id": str(scene_id)}, {"$set": update_doc}) + + # Return updated scene + updated_scene = mongodb_get_scene(scene_id) + if updated_scene is None: + raise ValueError(f"Scene {scene_id} not found after update") + + return updated_scene + + +def mongodb_list_scenes(params: SceneFilter) -> SceneListResponse: + """ + List scenes with filtering, sorting, and pagination. + + Authority: All agents + Use Case: DL-4 + + Args: + params: Filter and pagination parameters + + Returns: + SceneListResponse with list of scenes and pagination info + """ + mongo_client = get_mongodb_client() + scenes_collection = mongo_client.get_collection("scenes") + + # Build filter query + filter_query: Dict[str, Any] = {} + + if params.story_id is not None: + filter_query["story_id"] = str(params.story_id) + + if params.universe_id is not None: + filter_query["universe_id"] = str(params.universe_id) + + if params.status is not None: + filter_query["status"] = params.status.value + + # Count total matching documents + total = scenes_collection.count_documents(filter_query) + + # Build sort + sort_field = params.sort_by if params.sort_by in ["created_at", "order"] else "created_at" + sort_order = -1 if params.sort_order == "desc" else 1 + + # Query with pagination + cursor = ( + scenes_collection.find(filter_query) + .sort(sort_field, sort_order) + .skip(params.offset) + .limit(params.limit) + ) + + scenes = [] + for scene_doc in cursor: + # Convert turns from dict to TurnResponse + turns = [] + for turn_dict in scene_doc.get("turns", []): + turns.append( + TurnResponse( + turn_id=UUID(turn_dict["turn_id"]), + speaker=turn_dict["speaker"], + entity_id=UUID(turn_dict["entity_id"]) + if turn_dict.get("entity_id") + else None, + text=turn_dict["text"], + timestamp=turn_dict["timestamp"], + resolution_ref=UUID(turn_dict["resolution_ref"]) + if turn_dict.get("resolution_ref") + else None, + ) + ) + + scenes.append( + SceneResponse( + scene_id=UUID(scene_doc["scene_id"]), + story_id=UUID(scene_doc["story_id"]), + universe_id=UUID(scene_doc["universe_id"]), + title=scene_doc["title"], + purpose=scene_doc["purpose"], + status=SceneStatus(scene_doc["status"]), + order=scene_doc.get("order"), + location_ref=UUID(scene_doc["location_ref"]) + if scene_doc.get("location_ref") + else None, + participating_entities=[ + UUID(eid) for eid in scene_doc.get("participating_entities", []) + ], + turns=turns, + proposed_changes=[UUID(pid) for pid in scene_doc.get("proposed_changes", [])], + canonical_outcomes=[ + UUID(cid) for cid in scene_doc.get("canonical_outcomes", []) + ], + summary=scene_doc.get("summary", ""), + created_at=scene_doc["created_at"], + updated_at=scene_doc["updated_at"], + completed_at=scene_doc.get("completed_at"), + ) + ) + + return SceneListResponse(scenes=scenes, total=total, limit=params.limit, offset=params.offset) + + +def mongodb_append_turn(scene_id: UUID, params: TurnCreate) -> TurnResponse: + """ + Append a turn to a scene with proper ordering. + + Authority: All agents (primarily Narrator and Orchestrator) + Use Case: DL-4 + + Args: + scene_id: UUID of the scene to append turn to + params: Turn creation parameters + + Returns: + TurnResponse with created turn data + + Raises: + ValueError: If scene doesn't exist or scene is completed + """ + mongo_client = get_mongodb_client() + neo4j_client = get_neo4j_client() + scenes_collection = mongo_client.get_collection("scenes") + + # Verify scene exists + scene_doc = scenes_collection.find_one({"scene_id": str(scene_id)}) + if not scene_doc: + raise ValueError(f"Scene {scene_id} not found") + + # Check scene is not completed + if scene_doc["status"] == SceneStatus.COMPLETED.value: + raise ValueError(f"Cannot append turn to completed scene {scene_id}") + + # Verify entity_id if speaker is entity + if params.entity_id: + entity_check_query = """ + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + RETURN e.id as id + """ + result = neo4j_client.execute_read( + entity_check_query, {"entity_id": str(params.entity_id)} + ) + if not result: + raise ValueError(f"Entity {params.entity_id} not found") + + # Create turn + turn_id = uuid4() + timestamp = datetime.now(timezone.utc) + + turn_doc = { + "turn_id": str(turn_id), + "speaker": params.speaker.value, + "entity_id": str(params.entity_id) if params.entity_id else None, + "text": params.text, + "timestamp": timestamp, + "resolution_ref": None, + } + + # Append turn to scene + scenes_collection.update_one( + {"scene_id": str(scene_id)}, + {"$push": {"turns": turn_doc}, "$set": {"updated_at": timestamp}}, + ) + + return TurnResponse( + turn_id=turn_id, + speaker=params.speaker, + entity_id=params.entity_id, + text=params.text, + timestamp=timestamp, + resolution_ref=None, + ) diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py index 4532e3a..a4a0a83 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -40,6 +40,13 @@ EventResponse, EventFilter, ) +from monitor_data.schemas.stories import ( + StoryCreate, + StoryUpdate, + StoryResponse, + StoryFilter, + StoryListResponse, +) # ============================================================================= @@ -1970,3 +1977,330 @@ def neo4j_list_events(filters: Optional[EventFilter] = None) -> List[EventRespon return events + +# ============================================================================= +# STORY OPERATIONS +# ============================================================================= + + +def neo4j_create_story(params: StoryCreate) -> StoryResponse: + """ + Create a new Story node linked to universe. + + Authority: CanonKeeper, Orchestrator + Use Case: DL-4 + + Args: + params: Story creation parameters + + Returns: + StoryResponse with created story data + + Raises: + ValueError: If universe_id doesn't exist or pc_ids are invalid + """ + client = get_neo4j_client() + + # Verify universe exists + verify_query = """ + MATCH (u:Universe {id: $universe_id}) + RETURN u.id as id + """ + result = client.execute_read(verify_query, {"universe_id": str(params.universe_id)}) + if not result: + raise ValueError(f"Universe {params.universe_id} not found") + + # Verify player character entity IDs if provided + if params.pc_ids: + entity_check_query = """ + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + RETURN e.id as id + """ + for pc_id in params.pc_ids: + result = client.execute_read(entity_check_query, {"entity_id": str(pc_id)}) + if not result: + raise ValueError(f"Player character entity {pc_id} not found") + + # Create story + story_id = uuid4() + created_at = datetime.now(timezone.utc) + + create_query = """ + MATCH (u:Universe {id: $universe_id}) + CREATE (s:Story { + id: $id, + universe_id: $universe_id, + title: $title, + story_type: $story_type, + theme: $theme, + premise: $premise, + status: $status, + start_time_ref: datetime($start_time_ref), + created_at: datetime($created_at) + }) + CREATE (u)-[:HAS_STORY]->(s) + RETURN s + """ + create_params = { + "id": str(story_id), + "universe_id": str(params.universe_id), + "title": params.title, + "story_type": params.story_type.value, + "theme": params.theme, + "premise": params.premise, + "status": params.status.value, + "start_time_ref": params.start_time_ref.isoformat() if params.start_time_ref else None, + "created_at": created_at.isoformat(), + } + + result = client.execute_write(create_query, create_params) + s = result[0]["s"] + + # Create PARTICIPATES edges for player characters + if params.pc_ids: + for pc_id in params.pc_ids: + pc_edge_query = """ + MATCH (s:Story {id: $story_id}) + MATCH (pc {id: $pc_id}) + WHERE pc:EntityArchetype OR pc:EntityInstance + CREATE (pc)-[:PARTICIPATES]->(s) + """ + client.execute_write( + pc_edge_query, {"story_id": str(story_id), "pc_id": str(pc_id)} + ) + + return StoryResponse( + id=UUID(s["id"]), + universe_id=UUID(s["universe_id"]), + title=s["title"], + story_type=s["story_type"], + theme=s["theme"], + premise=s["premise"], + status=s["status"], + start_time_ref=s.get("start_time_ref"), + end_time_ref=s.get("end_time_ref"), + created_at=s["created_at"], + completed_at=s.get("completed_at"), + scene_count=0, + pc_ids=params.pc_ids, + ) + + +def neo4j_get_story(story_id: UUID) -> Optional[StoryResponse]: + """ + Retrieve a Story by ID with scene count and participant list. + + Authority: All agents + Use Case: DL-4 + + Args: + story_id: UUID of the story to retrieve + + Returns: + StoryResponse if found, None otherwise + """ + client = get_neo4j_client() + + query = """ + MATCH (s:Story {id: $id}) + OPTIONAL MATCH (s)-[:HAS_SCENE]->(sc:Scene) + OPTIONAL MATCH (pc)-[:PARTICIPATES]->(s) + WHERE pc:EntityArchetype OR pc:EntityInstance + RETURN s, + count(DISTINCT sc) as scene_count, + collect(DISTINCT pc.id) as pc_ids + """ + + result = client.execute_read(query, {"id": str(story_id)}) + if not result: + return None + + record = result[0] + s = record["s"] + scene_count = record["scene_count"] + pc_ids = [UUID(pc_id) for pc_id in record["pc_ids"] if pc_id] + + return StoryResponse( + id=UUID(s["id"]), + universe_id=UUID(s["universe_id"]), + title=s["title"], + story_type=s["story_type"], + theme=s["theme"], + premise=s["premise"], + status=s["status"], + start_time_ref=s.get("start_time_ref"), + end_time_ref=s.get("end_time_ref"), + created_at=s["created_at"], + completed_at=s.get("completed_at"), + scene_count=scene_count, + pc_ids=pc_ids, + ) + + +def neo4j_update_story(story_id: UUID, params: StoryUpdate) -> StoryResponse: + """ + Update a Story's mutable fields. + + Authority: CanonKeeper only + Use Case: DL-4 + + Args: + story_id: UUID of the story to update + params: Fields to update + + Returns: + StoryResponse with updated story data + + Raises: + ValueError: If story doesn't exist + """ + client = get_neo4j_client() + + # Verify story exists + verify_query = """ + MATCH (s:Story {id: $id}) + RETURN s + """ + result = client.execute_read(verify_query, {"id": str(story_id)}) + if not result: + raise ValueError(f"Story {story_id} not found") + + # Build update query dynamically + set_clauses = [] + update_params = {"id": str(story_id)} + + if params.title is not None: + set_clauses.append("s.title = $title") + update_params["title"] = params.title + + if params.theme is not None: + set_clauses.append("s.theme = $theme") + update_params["theme"] = params.theme + + if params.premise is not None: + set_clauses.append("s.premise = $premise") + update_params["premise"] = params.premise + + if params.status is not None: + set_clauses.append("s.status = $status") + update_params["status"] = params.status.value + # If completing the story, set completed_at + if params.status.value == "completed": + set_clauses.append("s.completed_at = datetime($completed_at)") + update_params["completed_at"] = datetime.now(timezone.utc).isoformat() + + if not set_clauses: + # No updates, just return current state + result = neo4j_get_story(story_id) + if result is None: + raise ValueError(f"Story {story_id} not found after verification") + return result + + set_clause = ", ".join(set_clauses) + update_query = "MATCH (s:Story {id: $id})\n" "SET " + set_clause + "\n" "RETURN s" + + result = client.execute_write(update_query, update_params) + s = result[0]["s"] + + # Get scene count and participants + story_data = neo4j_get_story(story_id) + if story_data is None: + raise ValueError(f"Story {story_id} not found after update") + + return story_data + + +def neo4j_list_stories(params: StoryFilter) -> StoryListResponse: + """ + List stories with filtering, sorting, and pagination. + + Authority: All agents + Use Case: DL-4 + + Args: + params: Filter and pagination parameters + + Returns: + StoryListResponse with list of stories and pagination info + """ + client = get_neo4j_client() + + # Build WHERE clauses + where_clauses = [] + query_params: Dict[str, Any] = {} + + if params.universe_id is not None: + where_clauses.append("s.universe_id = $universe_id") + query_params["universe_id"] = str(params.universe_id) + + if params.story_type is not None: + where_clauses.append("s.story_type = $story_type") + query_params["story_type"] = params.story_type.value + + if params.status is not None: + where_clauses.append("s.status = $status") + query_params["status"] = params.status.value + + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # Build ORDER BY clause + sort_field = params.sort_by if params.sort_by in ["created_at", "title"] else "created_at" + sort_order = "DESC" if params.sort_order == "desc" else "ASC" + order_clause = f"ORDER BY s.{sort_field} {sort_order}" + + # Count query + count_query = f""" + MATCH (s:Story) + {where_clause} + RETURN count(s) as total + """ + count_result = client.execute_read(count_query, query_params) + total = count_result[0]["total"] + + # List query with pagination + query_params["limit"] = params.limit + query_params["offset"] = params.offset + + list_query = f""" + MATCH (s:Story) + {where_clause} + OPTIONAL MATCH (s)-[:HAS_SCENE]->(sc:Scene) + OPTIONAL MATCH (pc)-[:PARTICIPATES]->(s) + WHERE pc:EntityArchetype OR pc:EntityInstance + WITH s, count(DISTINCT sc) as scene_count, collect(DISTINCT pc.id) as pc_ids + {order_clause} + SKIP $offset + LIMIT $limit + RETURN s, scene_count, pc_ids + """ + + results = client.execute_read(list_query, query_params) + + stories = [] + for record in results: + s = record["s"] + scene_count = record["scene_count"] + pc_ids = [UUID(pc_id) for pc_id in record["pc_ids"] if pc_id] + + stories.append( + StoryResponse( + id=UUID(s["id"]), + universe_id=UUID(s["universe_id"]), + title=s["title"], + story_type=s["story_type"], + theme=s["theme"], + premise=s["premise"], + status=s["status"], + start_time_ref=s.get("start_time_ref"), + end_time_ref=s.get("end_time_ref"), + created_at=s["created_at"], + completed_at=s.get("completed_at"), + scene_count=scene_count, + pc_ids=pc_ids, + ) + ) + + return StoryListResponse(stories=stories, total=total, limit=params.limit, offset=params.offset) + + From 4f7ab06596602c9da10534dbb95f94ecc0f62df5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:59:29 +0000 Subject: [PATCH 3/5] Add comprehensive tests for Story and Scene operations Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .../tests/test_tools/test_scene_tools.py | 610 ++++++++++++++++++ .../tests/test_tools/test_story_tools.py | 411 ++++++++++++ 2 files changed, 1021 insertions(+) create mode 100644 packages/data-layer/tests/test_tools/test_scene_tools.py create mode 100644 packages/data-layer/tests/test_tools/test_story_tools.py diff --git a/packages/data-layer/tests/test_tools/test_scene_tools.py b/packages/data-layer/tests/test_tools/test_scene_tools.py new file mode 100644 index 0000000..cced5ce --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_scene_tools.py @@ -0,0 +1,610 @@ +""" +Unit tests for MongoDB scene operations (DL-4). + +Tests cover: +- mongodb_create_scene +- mongodb_get_scene +- mongodb_update_scene +- mongodb_list_scenes +- mongodb_append_turn +""" + +from typing import Dict, Any +from unittest.mock import Mock, patch, MagicMock +from uuid import UUID, uuid4 +from datetime import datetime, timezone + +import pytest + +from monitor_data.schemas.scenes import ( + SceneCreate, + SceneUpdate, + SceneFilter, + TurnCreate, +) +from monitor_data.schemas.base import SceneStatus, Speaker +from monitor_data.tools.mongodb_tools import ( + mongodb_create_scene, + mongodb_get_scene, + mongodb_update_scene, + mongodb_list_scenes, + mongodb_append_turn, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def mock_mongodb_client() -> Mock: + """Provide a mock MongoDB client.""" + client = Mock() + collection = Mock() + client.get_collection.return_value = collection + return client + + +@pytest.fixture +def story_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample story data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "title": "Test Story", + } + + +@pytest.fixture +def scene_data(story_data: Dict[str, Any], universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample scene data.""" + return { + "scene_id": str(uuid4()), + "story_id": story_data["id"], + "universe_id": universe_data["id"], + "title": "Opening Scene", + "purpose": "Introduce the characters", + "status": SceneStatus.ACTIVE.value, + "order": 1, + "location_ref": None, + "participating_entities": [], + "turns": [], + "proposed_changes": [], + "canonical_outcomes": [], + "summary": "", + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "completed_at": None, + } + + +@pytest.fixture +def entity_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample entity data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "name": "Test Entity", + } + + +# ============================================================================= +# TESTS: mongodb_create_scene +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_scene_success( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + universe_data: Dict[str, Any], +): + """Test successful scene creation.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock Neo4j story/universe exists check + mock_neo4j_client.execute_read.return_value = [ + {"story_id": story_data["id"], "universe_id": universe_data["id"]} + ] + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.insert_one.return_value = Mock(inserted_id="mongo_obj_id") + + params = SceneCreate( + story_id=UUID(story_data["id"]), + universe_id=UUID(universe_data["id"]), + title="Opening Scene", + purpose="Introduce the characters", + order=1, + ) + + result = mongodb_create_scene(params) + + assert result.title == "Opening Scene" + assert result.story_id == UUID(story_data["id"]) + assert result.status == SceneStatus.ACTIVE + assert len(result.turns) == 0 + collection.insert_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_scene_with_participants( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + universe_data: Dict[str, Any], + entity_data: Dict[str, Any], +): + """Test scene creation with participating entities.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock Neo4j story/universe check and entity check + mock_neo4j_client.execute_read.side_effect = [ + [{"story_id": story_data["id"], "universe_id": universe_data["id"]}], + [{"id": entity_data["id"]}], # entity check + ] + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.insert_one.return_value = Mock(inserted_id="mongo_obj_id") + + entity_id = UUID(entity_data["id"]) + params = SceneCreate( + story_id=UUID(story_data["id"]), + universe_id=UUID(universe_data["id"]), + title="Opening Scene", + participating_entities=[entity_id], + ) + + result = mongodb_create_scene(params) + + assert entity_id in result.participating_entities + assert mock_neo4j_client.execute_read.call_count == 2 + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_scene_invalid_story( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, +): + """Test scene creation with invalid story_id.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock story doesn't exist + mock_neo4j_client.execute_read.return_value = [] + + params = SceneCreate( + story_id=uuid4(), + universe_id=uuid4(), + title="Test Scene", + ) + + with pytest.raises(ValueError, match="Story .* or Universe .* not found"): + mongodb_create_scene(params) + + +# ============================================================================= +# TESTS: mongodb_get_scene +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_scene_success( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test successful scene retrieval.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + + result = mongodb_get_scene(UUID(scene_data["scene_id"])) + + assert result is not None + assert result.scene_id == UUID(scene_data["scene_id"]) + assert result.title == "Opening Scene" + assert len(result.turns) == 0 + collection.find_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_scene_with_turns( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test scene retrieval with turns.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Add turns to scene data + turn_1 = { + "turn_id": str(uuid4()), + "speaker": Speaker.USER.value, + "entity_id": None, + "text": "Hello, world!", + "timestamp": datetime.now(timezone.utc), + "resolution_ref": None, + } + turn_2 = { + "turn_id": str(uuid4()), + "speaker": Speaker.GM.value, + "entity_id": None, + "text": "Welcome, adventurer!", + "timestamp": datetime.now(timezone.utc), + "resolution_ref": None, + } + scene_data["turns"] = [turn_1, turn_2] + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + + result = mongodb_get_scene(UUID(scene_data["scene_id"])) + + assert result is not None + assert len(result.turns) == 2 + assert result.turns[0].text == "Hello, world!" + assert result.turns[1].text == "Welcome, adventurer!" + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_scene_not_found( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, +): + """Test scene retrieval when scene doesn't exist.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = None + + result = mongodb_get_scene(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: mongodb_update_scene +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_scene_title( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test updating scene title.""" + mock_get_mongo.return_value = mock_mongodb_client + + updated_data = scene_data.copy() + updated_data["title"] = "New Scene Title" + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.side_effect = [scene_data, updated_data] + collection.update_one.return_value = Mock(modified_count=1) + + params = SceneUpdate(title="New Scene Title") + result = mongodb_update_scene(UUID(scene_data["scene_id"]), params) + + assert result.title == "New Scene Title" + collection.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_scene_status_valid_transition( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test updating scene status with valid transition.""" + mock_get_mongo.return_value = mock_mongodb_client + + updated_data = scene_data.copy() + updated_data["status"] = SceneStatus.FINALIZING.value + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.side_effect = [scene_data, updated_data] + collection.update_one.return_value = Mock(modified_count=1) + + params = SceneUpdate(status=SceneStatus.FINALIZING) + result = mongodb_update_scene(UUID(scene_data["scene_id"]), params) + + assert result.status == SceneStatus.FINALIZING + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_scene_status_invalid_transition( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test updating scene status with invalid transition.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Scene is already completed + scene_data["status"] = SceneStatus.COMPLETED.value + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + + params = SceneUpdate(status=SceneStatus.ACTIVE) + + with pytest.raises(ValueError, match="Invalid status transition"): + mongodb_update_scene(UUID(scene_data["scene_id"]), params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_scene_not_found( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, +): + """Test updating non-existent scene.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = None + + params = SceneUpdate(title="New Title") + + with pytest.raises(ValueError, match="Scene .* not found"): + mongodb_update_scene(uuid4(), params) + + +# ============================================================================= +# TESTS: mongodb_list_scenes +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_scenes_success( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test listing scenes with no filters.""" + mock_get_mongo.return_value = mock_mongodb_client + + scene_data_2 = scene_data.copy() + scene_data_2["scene_id"] = str(uuid4()) + scene_data_2["title"] = "Second Scene" + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 2 + + # Mock cursor + cursor = MagicMock() + cursor.sort.return_value = cursor + cursor.skip.return_value = cursor + cursor.limit.return_value = cursor + cursor.__iter__.return_value = iter([scene_data, scene_data_2]) + collection.find.return_value = cursor + + params = SceneFilter() + result = mongodb_list_scenes(params) + + assert result.total == 2 + assert len(result.scenes) == 2 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_list_scenes_filtered_by_story( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + story_data: Dict[str, Any], + scene_data: Dict[str, Any], +): + """Test listing scenes filtered by story_id.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.count_documents.return_value = 1 + + # Mock cursor + cursor = MagicMock() + cursor.sort.return_value = cursor + cursor.skip.return_value = cursor + cursor.limit.return_value = cursor + cursor.__iter__.return_value = iter([scene_data]) + collection.find.return_value = cursor + + params = SceneFilter(story_id=UUID(story_data["id"])) + result = mongodb_list_scenes(params) + + assert result.total == 1 + assert len(result.scenes) == 1 + assert result.scenes[0].story_id == UUID(story_data["id"]) + + +# ============================================================================= +# TESTS: mongodb_append_turn +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_append_turn_success( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + scene_data: Dict[str, Any], +): + """Test successful turn append.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + collection.update_one.return_value = Mock(modified_count=1) + + params = TurnCreate( + speaker=Speaker.USER, + text="I draw my sword!", + ) + + result = mongodb_append_turn(UUID(scene_data["scene_id"]), params) + + assert result.text == "I draw my sword!" + assert result.speaker == Speaker.USER + collection.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_append_turn_with_entity( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + scene_data: Dict[str, Any], + entity_data: Dict[str, Any], +): + """Test appending turn with entity speaker.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + collection.update_one.return_value = Mock(modified_count=1) + + # Mock Neo4j entity check + mock_neo4j_client.execute_read.return_value = [{"id": entity_data["id"]}] + + entity_id = UUID(entity_data["id"]) + params = TurnCreate( + speaker=Speaker.ENTITY, + entity_id=entity_id, + text="I attack the orc!", + ) + + result = mongodb_append_turn(UUID(scene_data["scene_id"]), params) + + assert result.text == "I attack the orc!" + assert result.speaker == Speaker.ENTITY + assert result.entity_id == entity_id + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_append_turn_to_completed_scene( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test appending turn to completed scene fails.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Scene is completed + scene_data["status"] = SceneStatus.COMPLETED.value + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + + params = TurnCreate( + speaker=Speaker.USER, + text="This should fail", + ) + + with pytest.raises(ValueError, match="Cannot append turn to completed scene"): + mongodb_append_turn(UUID(scene_data["scene_id"]), params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_append_turn_scene_not_found( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, +): + """Test appending turn to non-existent scene.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = None + + params = TurnCreate( + speaker=Speaker.USER, + text="Test text", + ) + + with pytest.raises(ValueError, match="Scene .* not found"): + mongodb_append_turn(uuid4(), params) + + +# ============================================================================= +# TESTS: Scene status transitions +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_scene_status_transition_active_to_completed( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test valid status transition: active → completed.""" + mock_get_mongo.return_value = mock_mongodb_client + + updated_data = scene_data.copy() + updated_data["status"] = SceneStatus.COMPLETED.value + updated_data["completed_at"] = datetime.now(timezone.utc) + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.side_effect = [scene_data, updated_data] + collection.update_one.return_value = Mock(modified_count=1) + + params = SceneUpdate(status=SceneStatus.COMPLETED) + result = mongodb_update_scene(UUID(scene_data["scene_id"]), params) + + assert result.status == SceneStatus.COMPLETED + assert result.completed_at is not None + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_scene_status_transition_finalizing_to_active_invalid( + mock_get_mongo: Mock, + mock_mongodb_client: Mock, + scene_data: Dict[str, Any], +): + """Test invalid status transition: finalizing → active.""" + mock_get_mongo.return_value = mock_mongodb_client + + # Scene is finalizing + scene_data["status"] = SceneStatus.FINALIZING.value + + # Mock MongoDB collection + collection = mock_mongodb_client.get_collection.return_value + collection.find_one.return_value = scene_data + + params = SceneUpdate(status=SceneStatus.ACTIVE) + + with pytest.raises(ValueError, match="Invalid status transition"): + mongodb_update_scene(UUID(scene_data["scene_id"]), params) diff --git a/packages/data-layer/tests/test_tools/test_story_tools.py b/packages/data-layer/tests/test_tools/test_story_tools.py new file mode 100644 index 0000000..7bb8b2f --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_story_tools.py @@ -0,0 +1,411 @@ +""" +Unit tests for Neo4j story operations (DL-4). + +Tests cover: +- neo4j_create_story +- neo4j_get_story +- neo4j_update_story +- neo4j_list_stories +""" + +from typing import Dict, Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 +from datetime import datetime + +import pytest + +from monitor_data.schemas.stories import ( + StoryCreate, + StoryUpdate, + StoryFilter, +) +from monitor_data.schemas.base import StoryType, StoryStatus +from monitor_data.tools.neo4j_tools import ( + neo4j_create_story, + neo4j_get_story, + neo4j_update_story, + neo4j_list_stories, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def story_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample story data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "title": "The Quest for the Ancient Artifact", + "story_type": StoryType.CAMPAIGN.value, + "theme": "Heroes vs Ancient Evil", + "premise": "A group of adventurers seeks a powerful artifact", + "status": StoryStatus.PLANNED.value, + "start_time_ref": None, + "end_time_ref": None, + "created_at": datetime.fromisoformat("2024-01-01T00:00:00"), + "completed_at": None, + } + + +@pytest.fixture +def pc_entity_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample player character entity data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "name": "Aragorn", + "entity_type": "character", + "is_archetype": False, + } + + +# ============================================================================= +# TESTS: neo4j_create_story +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_story_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + story_data: Dict[str, Any], +): + """Test successful story creation.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists check + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock story creation + mock_neo4j_client.execute_write.return_value = [{"s": story_data}] + + params = StoryCreate( + universe_id=UUID(universe_data["id"]), + title="The Quest for the Ancient Artifact", + story_type=StoryType.CAMPAIGN, + theme="Heroes vs Ancient Evil", + premise="A group of adventurers seeks a powerful artifact", + status=StoryStatus.PLANNED, + ) + + result = neo4j_create_story(params) + + assert result.title == "The Quest for the Ancient Artifact" + assert result.universe_id == UUID(universe_data["id"]) + assert result.story_type == StoryType.CAMPAIGN + assert result.status == StoryStatus.PLANNED + assert result.scene_count == 0 + assert mock_neo4j_client.execute_read.call_count >= 1 + assert mock_neo4j_client.execute_write.call_count >= 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_story_with_pcs( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + story_data: Dict[str, Any], + pc_entity_data: Dict[str, Any], +): + """Test story creation with player characters.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe and entity checks + mock_neo4j_client.execute_read.side_effect = [ + [{"id": universe_data["id"]}], # universe check + [{"id": pc_entity_data["id"]}], # pc check + ] + + # Mock story creation and PC edge creation + mock_neo4j_client.execute_write.return_value = [{"s": story_data}] + + pc_id = UUID(pc_entity_data["id"]) + params = StoryCreate( + universe_id=UUID(universe_data["id"]), + title="The Quest for the Ancient Artifact", + story_type=StoryType.CAMPAIGN, + theme="Heroes vs Ancient Evil", + premise="A group of adventurers seeks a powerful artifact", + pc_ids=[pc_id], + ) + + result = neo4j_create_story(params) + + assert result.title == "The Quest for the Ancient Artifact" + assert pc_id in result.pc_ids + # 1 universe check + 1 pc check + assert mock_neo4j_client.execute_read.call_count == 2 + # 1 story creation + 1 PC edge + assert mock_neo4j_client.execute_write.call_count == 2 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_story_invalid_universe( + mock_get_client: Mock, mock_neo4j_client: Mock +): + """Test story creation with invalid universe_id.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe doesn't exist + mock_neo4j_client.execute_read.return_value = [] + + params = StoryCreate( + universe_id=uuid4(), + title="Test Story", + story_type=StoryType.CAMPAIGN, + ) + + with pytest.raises(ValueError, match="Universe .* not found"): + neo4j_create_story(params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_story_invalid_pc( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], +): + """Test story creation with invalid player character entity.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists, but PC doesn't + mock_neo4j_client.execute_read.side_effect = [ + [{"id": universe_data["id"]}], # universe check + [], # pc check fails + ] + + params = StoryCreate( + universe_id=UUID(universe_data["id"]), + title="Test Story", + pc_ids=[uuid4()], + ) + + with pytest.raises(ValueError, match="Player character entity .* not found"): + neo4j_create_story(params) + + +# ============================================================================= +# TESTS: neo4j_get_story +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_story_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test successful story retrieval.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock story query result + mock_neo4j_client.execute_read.return_value = [ + {"s": story_data, "scene_count": 3, "pc_ids": [str(uuid4()), str(uuid4())]} + ] + + result = neo4j_get_story(UUID(story_data["id"])) + + assert result is not None + assert result.id == UUID(story_data["id"]) + assert result.title == "The Quest for the Ancient Artifact" + assert result.scene_count == 3 + assert len(result.pc_ids) == 2 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_story_not_found(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test story retrieval when story doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock empty result + mock_neo4j_client.execute_read.return_value = [] + + result = neo4j_get_story(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: neo4j_update_story +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_title( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test updating story title.""" + mock_get_client.return_value = mock_neo4j_client + + updated_data = story_data.copy() + updated_data["title"] = "New Story Title" + + # Mock story exists check, update, and get + mock_neo4j_client.execute_read.side_effect = [ + [{"s": story_data}], # verify exists + [{"s": updated_data, "scene_count": 0, "pc_ids": []}], # get after update + ] + mock_neo4j_client.execute_write.return_value = [{"s": updated_data}] + + params = StoryUpdate(title="New Story Title") + result = neo4j_update_story(UUID(story_data["id"]), params) + + assert result.title == "New Story Title" + assert mock_neo4j_client.execute_write.call_count == 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_status( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test updating story status to completed.""" + mock_get_client.return_value = mock_neo4j_client + + updated_data = story_data.copy() + updated_data["status"] = StoryStatus.COMPLETED.value + updated_data["completed_at"] = datetime.now() + + # Mock story exists check, update, and get + mock_neo4j_client.execute_read.side_effect = [ + [{"s": story_data}], # verify exists + [{"s": updated_data, "scene_count": 0, "pc_ids": []}], # get after update + ] + mock_neo4j_client.execute_write.return_value = [{"s": updated_data}] + + params = StoryUpdate(status=StoryStatus.COMPLETED) + result = neo4j_update_story(UUID(story_data["id"]), params) + + assert result.status == StoryStatus.COMPLETED + assert result.completed_at is not None + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_not_found(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test updating non-existent story.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock story doesn't exist + mock_neo4j_client.execute_read.return_value = [] + + params = StoryUpdate(title="New Title") + + with pytest.raises(ValueError, match="Story .* not found"): + neo4j_update_story(uuid4(), params) + + +# ============================================================================= +# TESTS: neo4j_list_stories +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_stories_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + story_data: Dict[str, Any], +): + """Test listing stories with no filters.""" + mock_get_client.return_value = mock_neo4j_client + + story_data_2 = story_data.copy() + story_data_2["id"] = str(uuid4()) + story_data_2["title"] = "Another Story" + + # Mock count and list queries + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 2}], # count + [ + {"s": story_data, "scene_count": 3, "pc_ids": []}, + {"s": story_data_2, "scene_count": 1, "pc_ids": []}, + ], # list + ] + + params = StoryFilter() + result = neo4j_list_stories(params) + + assert result.total == 2 + assert len(result.stories) == 2 + assert result.stories[0].title == "The Quest for the Ancient Artifact" + assert result.stories[1].title == "Another Story" + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_stories_filtered_by_universe( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + story_data: Dict[str, Any], +): + """Test listing stories filtered by universe_id.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock count and list queries + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], # count + [{"s": story_data, "scene_count": 3, "pc_ids": []}], # list + ] + + params = StoryFilter(universe_id=UUID(universe_data["id"])) + result = neo4j_list_stories(params) + + assert result.total == 1 + assert len(result.stories) == 1 + assert result.stories[0].universe_id == UUID(universe_data["id"]) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_stories_filtered_by_status( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test listing stories filtered by status.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock count and list queries + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], # count + [{"s": story_data, "scene_count": 0, "pc_ids": []}], # list + ] + + params = StoryFilter(status=StoryStatus.PLANNED) + result = neo4j_list_stories(params) + + assert result.total == 1 + assert len(result.stories) == 1 + assert result.stories[0].status == StoryStatus.PLANNED + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_stories_pagination( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test listing stories with pagination.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock count and list queries + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 10}], # count + [{"s": story_data, "scene_count": 0, "pc_ids": []}], # list (1 result) + ] + + params = StoryFilter(limit=1, offset=5) + result = neo4j_list_stories(params) + + assert result.total == 10 + assert result.limit == 1 + assert result.offset == 5 + assert len(result.stories) == 1 From 4eee22c489c9951df54d28d9ad04841949d3a750 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:20:37 +0000 Subject: [PATCH 4/5] Fix code review issues: remove unused imports/vars, add helper functions, fix comments Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .../data-layer/src/monitor_data/db/mongodb.py | 12 +- .../src/monitor_data/schemas/scenes.py | 2 +- .../src/monitor_data/tools/mongodb_tools.py | 150 ++++++++---------- .../src/monitor_data/tools/neo4j_tools.py | 3 +- 4 files changed, 74 insertions(+), 93 deletions(-) diff --git a/packages/data-layer/src/monitor_data/db/mongodb.py b/packages/data-layer/src/monitor_data/db/mongodb.py index 2382b43..93ac91e 100644 --- a/packages/data-layer/src/monitor_data/db/mongodb.py +++ b/packages/data-layer/src/monitor_data/db/mongodb.py @@ -20,7 +20,7 @@ """ import os -from typing import Optional, Dict, Any, List +from typing import Optional from pymongo import MongoClient, ASCENDING, DESCENDING from pymongo.database import Database from pymongo.collection import Collection @@ -34,10 +34,6 @@ class MongoDBClient: Manages connection lifecycle and provides collection access. """ - _instance: Optional["MongoDBClient"] = None - _client: Optional[MongoClient] = None - _db: Optional[Database] = None - def __init__( self, uri: Optional[str] = None, @@ -54,6 +50,7 @@ def __init__( self.database_name = database or os.getenv("MONGODB_DATABASE", "monitor") self._client = None self._db = None + self._indexes_created = False def connect(self) -> None: """ @@ -64,7 +61,9 @@ def connect(self) -> None: if self._client is None: self._client = MongoClient(self.uri) self._db = self._client[self.database_name] - self._create_indexes() + if not self._indexes_created: + self._create_indexes() + self._indexes_created = True def close(self) -> None: """Close MongoDB connection.""" @@ -72,6 +71,7 @@ def close(self) -> None: self._client.close() self._client = None self._db = None + self._indexes_created = False def verify_connectivity(self) -> bool: """ diff --git a/packages/data-layer/src/monitor_data/schemas/scenes.py b/packages/data-layer/src/monitor_data/schemas/scenes.py index 8d7b0f8..d356611 100644 --- a/packages/data-layer/src/monitor_data/schemas/scenes.py +++ b/packages/data-layer/src/monitor_data/schemas/scenes.py @@ -10,7 +10,7 @@ Turns are individual exchanges within scenes. """ -from datetime import datetime, timezone +from datetime import datetime from typing import Optional, List from uuid import UUID diff --git a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py index 1d9cea4..b2090c5 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -10,7 +10,7 @@ """ from datetime import datetime, timezone -from typing import Optional, List, Dict, Any +from typing import Optional, Dict, Any from uuid import UUID, uuid4 from monitor_data.db.mongodb import get_mongodb_client @@ -27,6 +27,66 @@ from monitor_data.schemas.base import SceneStatus +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + + +def _convert_turn_dict_to_response(turn_dict: Dict[str, Any]) -> TurnResponse: + """ + Convert a turn dictionary from MongoDB to a TurnResponse object. + + Args: + turn_dict: Turn data from MongoDB document + + Returns: + TurnResponse object + """ + return TurnResponse( + turn_id=UUID(turn_dict["turn_id"]), + speaker=turn_dict["speaker"], + entity_id=UUID(turn_dict["entity_id"]) if turn_dict.get("entity_id") else None, + text=turn_dict["text"], + timestamp=turn_dict["timestamp"], + resolution_ref=UUID(turn_dict["resolution_ref"]) + if turn_dict.get("resolution_ref") + else None, + ) + + +def _convert_scene_doc_to_response(scene_doc: Dict[str, Any]) -> SceneResponse: + """ + Convert a scene document from MongoDB to a SceneResponse object. + + Args: + scene_doc: Scene data from MongoDB document + + Returns: + SceneResponse object + """ + # Convert turns from dict to TurnResponse + turns = [_convert_turn_dict_to_response(turn_dict) for turn_dict in scene_doc.get("turns", [])] + + return SceneResponse( + scene_id=UUID(scene_doc["scene_id"]), + story_id=UUID(scene_doc["story_id"]), + universe_id=UUID(scene_doc["universe_id"]), + title=scene_doc["title"], + purpose=scene_doc["purpose"], + status=SceneStatus(scene_doc["status"]), + order=scene_doc.get("order"), + location_ref=UUID(scene_doc["location_ref"]) if scene_doc.get("location_ref") else None, + participating_entities=[UUID(eid) for eid in scene_doc.get("participating_entities", [])], + turns=turns, + proposed_changes=[UUID(pid) for pid in scene_doc.get("proposed_changes", [])], + canonical_outcomes=[UUID(cid) for cid in scene_doc.get("canonical_outcomes", [])], + summary=scene_doc.get("summary", ""), + created_at=scene_doc["created_at"], + updated_at=scene_doc["updated_at"], + completed_at=scene_doc.get("completed_at"), + ) + + # ============================================================================= # SCENE OPERATIONS # ============================================================================= @@ -36,7 +96,7 @@ def mongodb_create_scene(params: SceneCreate) -> SceneResponse: """ Create a new Scene document in MongoDB. - Authority: Orchestrator only + Authority: CanonKeeper and Narrator agents Use Case: DL-4 Args: @@ -158,47 +218,14 @@ def mongodb_get_scene(scene_id: UUID) -> Optional[SceneResponse]: if not scene_doc: return None - # Convert turns from dict to TurnResponse - turns = [] - for turn_dict in scene_doc.get("turns", []): - turns.append( - TurnResponse( - turn_id=UUID(turn_dict["turn_id"]), - speaker=turn_dict["speaker"], - entity_id=UUID(turn_dict["entity_id"]) if turn_dict.get("entity_id") else None, - text=turn_dict["text"], - timestamp=turn_dict["timestamp"], - resolution_ref=UUID(turn_dict["resolution_ref"]) - if turn_dict.get("resolution_ref") - else None, - ) - ) - - return SceneResponse( - scene_id=UUID(scene_doc["scene_id"]), - story_id=UUID(scene_doc["story_id"]), - universe_id=UUID(scene_doc["universe_id"]), - title=scene_doc["title"], - purpose=scene_doc["purpose"], - status=SceneStatus(scene_doc["status"]), - order=scene_doc.get("order"), - location_ref=UUID(scene_doc["location_ref"]) if scene_doc.get("location_ref") else None, - participating_entities=[UUID(eid) for eid in scene_doc.get("participating_entities", [])], - turns=turns, - proposed_changes=[UUID(pid) for pid in scene_doc.get("proposed_changes", [])], - canonical_outcomes=[UUID(cid) for cid in scene_doc.get("canonical_outcomes", [])], - summary=scene_doc.get("summary", ""), - created_at=scene_doc["created_at"], - updated_at=scene_doc["updated_at"], - completed_at=scene_doc.get("completed_at"), - ) + return _convert_scene_doc_to_response(scene_doc) def mongodb_update_scene(scene_id: UUID, params: SceneUpdate) -> SceneResponse: """ Update a Scene's mutable fields with status transition enforcement. - Authority: Orchestrator only + Authority: CanonKeeper and Narrator agents Use Case: DL-4 Valid status transitions: active → finalizing → completed @@ -312,52 +339,7 @@ def mongodb_list_scenes(params: SceneFilter) -> SceneListResponse: .limit(params.limit) ) - scenes = [] - for scene_doc in cursor: - # Convert turns from dict to TurnResponse - turns = [] - for turn_dict in scene_doc.get("turns", []): - turns.append( - TurnResponse( - turn_id=UUID(turn_dict["turn_id"]), - speaker=turn_dict["speaker"], - entity_id=UUID(turn_dict["entity_id"]) - if turn_dict.get("entity_id") - else None, - text=turn_dict["text"], - timestamp=turn_dict["timestamp"], - resolution_ref=UUID(turn_dict["resolution_ref"]) - if turn_dict.get("resolution_ref") - else None, - ) - ) - - scenes.append( - SceneResponse( - scene_id=UUID(scene_doc["scene_id"]), - story_id=UUID(scene_doc["story_id"]), - universe_id=UUID(scene_doc["universe_id"]), - title=scene_doc["title"], - purpose=scene_doc["purpose"], - status=SceneStatus(scene_doc["status"]), - order=scene_doc.get("order"), - location_ref=UUID(scene_doc["location_ref"]) - if scene_doc.get("location_ref") - else None, - participating_entities=[ - UUID(eid) for eid in scene_doc.get("participating_entities", []) - ], - turns=turns, - proposed_changes=[UUID(pid) for pid in scene_doc.get("proposed_changes", [])], - canonical_outcomes=[ - UUID(cid) for cid in scene_doc.get("canonical_outcomes", []) - ], - summary=scene_doc.get("summary", ""), - created_at=scene_doc["created_at"], - updated_at=scene_doc["updated_at"], - completed_at=scene_doc.get("completed_at"), - ) - ) + scenes = [_convert_scene_doc_to_response(scene_doc) for scene_doc in cursor] return SceneListResponse(scenes=scenes, total=total, limit=params.limit, offset=params.offset) @@ -366,7 +348,7 @@ def mongodb_append_turn(scene_id: UUID, params: TurnCreate) -> TurnResponse: """ Append a turn to a scene with proper ordering. - Authority: All agents (primarily Narrator and Orchestrator) + Authority: * (all agents; typically Narrator and Orchestrator) Use Case: DL-4 Args: diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py index a4a0a83..b2e1126 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -2200,8 +2200,7 @@ def neo4j_update_story(story_id: UUID, params: StoryUpdate) -> StoryResponse: set_clause = ", ".join(set_clauses) update_query = "MATCH (s:Story {id: $id})\n" "SET " + set_clause + "\n" "RETURN s" - result = client.execute_write(update_query, update_params) - s = result[0]["s"] + client.execute_write(update_query, update_params) # Get scene count and participants story_data = neo4j_get_story(story_id) From a3089d9241ce2af445d8f57b0d3a8e9ebdb88407 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:22:41 +0000 Subject: [PATCH 5/5] Add story status transition validation and missing test coverage Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .../src/monitor_data/tools/neo4j_tools.py | 32 +++++- .../tests/test_tools/test_scene_tools.py | 79 +++++++++++++++ .../tests/test_tools/test_story_tools.py | 99 +++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py index b2e1126..ab0bd4d 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -2140,11 +2140,13 @@ def neo4j_get_story(story_id: UUID) -> Optional[StoryResponse]: def neo4j_update_story(story_id: UUID, params: StoryUpdate) -> StoryResponse: """ - Update a Story's mutable fields. + Update a Story's mutable fields with status transition enforcement. Authority: CanonKeeper only Use Case: DL-4 + Valid status transitions: planned → active → completed/abandoned + Args: story_id: UUID of the story to update params: Fields to update @@ -2153,11 +2155,13 @@ def neo4j_update_story(story_id: UUID, params: StoryUpdate) -> StoryResponse: StoryResponse with updated story data Raises: - ValueError: If story doesn't exist + ValueError: If story doesn't exist or invalid status transition """ + from monitor_data.schemas.base import StoryStatus + client = get_neo4j_client() - # Verify story exists + # Verify story exists and get current status verify_query = """ MATCH (s:Story {id: $id}) RETURN s @@ -2165,6 +2169,28 @@ def neo4j_update_story(story_id: UUID, params: StoryUpdate) -> StoryResponse: result = client.execute_read(verify_query, {"id": str(story_id)}) if not result: raise ValueError(f"Story {story_id} not found") + + current_story = result[0]["s"] + + # Validate status transition if status is being updated + if params.status is not None: + current_status = StoryStatus(current_story["status"]) + new_status = params.status + + # Define valid transitions + valid_transitions = { + StoryStatus.PLANNED: [StoryStatus.ACTIVE, StoryStatus.ABANDONED], + StoryStatus.ACTIVE: [StoryStatus.COMPLETED, StoryStatus.ABANDONED], + StoryStatus.COMPLETED: [], # No transitions from completed + StoryStatus.ABANDONED: [], # No transitions from abandoned + } + + if new_status != current_status: + if new_status not in valid_transitions.get(current_status, []): + raise ValueError( + f"Invalid status transition from {current_status.value} to {new_status.value}. " + f"Valid transitions: {[s.value for s in valid_transitions.get(current_status, [])]}" + ) # Build update query dynamically set_clauses = [] diff --git a/packages/data-layer/tests/test_tools/test_scene_tools.py b/packages/data-layer/tests/test_tools/test_scene_tools.py index cced5ce..4fb96f9 100644 --- a/packages/data-layer/tests/test_tools/test_scene_tools.py +++ b/packages/data-layer/tests/test_tools/test_scene_tools.py @@ -608,3 +608,82 @@ def test_scene_status_transition_finalizing_to_active_invalid( with pytest.raises(ValueError, match="Invalid status transition"): mongodb_update_scene(UUID(scene_data["scene_id"]), params) + + +# ============================================================================= +# TESTS: Additional validation coverage +# ============================================================================= + + +def test_turn_create_entity_without_entity_id(): + """Test that TurnCreate validation fails when speaker is ENTITY but entity_id is None.""" + with pytest.raises(ValueError, match="entity_id required when speaker is entity"): + TurnCreate( + speaker=Speaker.ENTITY, + entity_id=None, + text="This should fail", + ) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_scene_invalid_entity( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + universe_data: Dict[str, Any], +): + """Test scene creation with invalid participating entity.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock story exists but entity doesn't + mock_neo4j_client.execute_read.side_effect = [ + [{"story_id": story_data["id"], "universe_id": universe_data["id"]}], + [], # entity check fails + ] + + invalid_entity_id = uuid4() + params = SceneCreate( + story_id=UUID(story_data["id"]), + universe_id=UUID(universe_data["id"]), + title="Test Scene", + participating_entities=[invalid_entity_id], + ) + + with pytest.raises(ValueError, match=f"Entity {invalid_entity_id} not found"): + mongodb_create_scene(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_scene_invalid_location( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_mongodb_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + universe_data: Dict[str, Any], +): + """Test scene creation with invalid location_ref.""" + mock_get_mongo.return_value = mock_mongodb_client + mock_get_neo4j.return_value = mock_neo4j_client + + # Mock story exists but location doesn't + mock_neo4j_client.execute_read.side_effect = [ + [{"story_id": story_data["id"], "universe_id": universe_data["id"]}], + [], # location check fails + ] + + invalid_location_id = uuid4() + params = SceneCreate( + story_id=UUID(story_data["id"]), + universe_id=UUID(universe_data["id"]), + title="Test Scene", + location_ref=invalid_location_id, + ) + + with pytest.raises(ValueError, match=f"Location entity {invalid_location_id} not found"): + mongodb_create_scene(params) diff --git a/packages/data-layer/tests/test_tools/test_story_tools.py b/packages/data-layer/tests/test_tools/test_story_tools.py index 7bb8b2f..f819d66 100644 --- a/packages/data-layer/tests/test_tools/test_story_tools.py +++ b/packages/data-layer/tests/test_tools/test_story_tools.py @@ -409,3 +409,102 @@ def test_list_stories_pagination( assert result.limit == 1 assert result.offset == 5 assert len(result.stories) == 1 + + +# ============================================================================= +# TESTS: Story status transitions +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_status_valid_transition_planned_to_active( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test valid status transition: planned → active.""" + mock_get_client.return_value = mock_neo4j_client + + story_data["status"] = StoryStatus.PLANNED.value + updated_data = story_data.copy() + updated_data["status"] = StoryStatus.ACTIVE.value + + # Mock story exists check, update, and get + mock_neo4j_client.execute_read.side_effect = [ + [{"s": story_data}], # verify exists + [{"s": updated_data, "scene_count": 0, "pc_ids": []}], # get after update + ] + mock_neo4j_client.execute_write.return_value = [{"s": updated_data}] + + params = StoryUpdate(status=StoryStatus.ACTIVE) + result = neo4j_update_story(UUID(story_data["id"]), params) + + assert result.status == StoryStatus.ACTIVE + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_status_valid_transition_active_to_completed( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test valid status transition: active → completed.""" + mock_get_client.return_value = mock_neo4j_client + + story_data["status"] = StoryStatus.ACTIVE.value + updated_data = story_data.copy() + updated_data["status"] = StoryStatus.COMPLETED.value + + # Mock story exists check, update, and get + mock_neo4j_client.execute_read.side_effect = [ + [{"s": story_data}], # verify exists + [{"s": updated_data, "scene_count": 0, "pc_ids": []}], # get after update + ] + mock_neo4j_client.execute_write.return_value = [{"s": updated_data}] + + params = StoryUpdate(status=StoryStatus.COMPLETED) + result = neo4j_update_story(UUID(story_data["id"]), params) + + assert result.status == StoryStatus.COMPLETED + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_status_invalid_transition_completed_to_active( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test invalid status transition: completed → active.""" + mock_get_client.return_value = mock_neo4j_client + + # Story is already completed + story_data["status"] = StoryStatus.COMPLETED.value + + # Mock story exists check + mock_neo4j_client.execute_read.return_value = [{"s": story_data}] + + params = StoryUpdate(status=StoryStatus.ACTIVE) + + with pytest.raises(ValueError, match="Invalid status transition"): + neo4j_update_story(UUID(story_data["id"]), params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_story_status_invalid_transition_planned_to_completed( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test invalid status transition: planned → completed (must go through active).""" + mock_get_client.return_value = mock_neo4j_client + + # Story is planned + story_data["status"] = StoryStatus.PLANNED.value + + # Mock story exists check + mock_neo4j_client.execute_read.return_value = [{"s": story_data}] + + params = StoryUpdate(status=StoryStatus.COMPLETED) + + with pytest.raises(ValueError, match="Invalid status transition"): + neo4j_update_story(UUID(story_data["id"]), params)