diff --git a/.gitignore b/.gitignore index b59652d..be50e65 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ venv/ htmlcov/ .coverage .pytest_cache/ + +# MCP plugin workspace +.serena/ + +# Package lock files +uv.lock diff --git a/packages/data-layer/src/monitor_data/schemas/base.py b/packages/data-layer/src/monitor_data/schemas/base.py index 63be079..0477d94 100644 --- a/packages/data-layer/src/monitor_data/schemas/base.py +++ b/packages/data-layer/src/monitor_data/schemas/base.py @@ -201,9 +201,9 @@ class ThreadUrgency(str, Enum): class ClueVisibility(str, Enum): """Visibility status for mystery clues.""" - REVEALED = "revealed" HIDDEN = "hidden" DISCOVERED = "discovered" + REVEALED = "revealed" class PayoffStatus(str, Enum): diff --git a/packages/data-layer/src/monitor_data/schemas/story_outlines.py b/packages/data-layer/src/monitor_data/schemas/story_outlines.py index 5609349..4172075 100644 --- a/packages/data-layer/src/monitor_data/schemas/story_outlines.py +++ b/packages/data-layer/src/monitor_data/schemas/story_outlines.py @@ -12,7 +12,7 @@ DL-6: Comprehensive implementation with narrative engine support """ -from datetime import datetime +from datetime import datetime, timezone from typing import Optional, List, Dict, Any from uuid import UUID, uuid4 @@ -41,7 +41,7 @@ class StoryBeat(BaseModel): beat_id: UUID = Field(default_factory=uuid4) title: str = Field(min_length=1, max_length=200) - description: str = Field(max_length=2000) + description: str = Field(default="", max_length=2000) order: int = Field(ge=0, description="Display order in story") status: BeatStatus = Field(default=BeatStatus.PENDING) optional: bool = Field( @@ -55,7 +55,7 @@ class StoryBeat(BaseModel): default_factory=list, description="PlotThreads that must be active for this beat to trigger", ) - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) started_at: Optional[datetime] = Field( None, description="When status changed to in_progress" ) @@ -161,7 +161,7 @@ class PacingMetrics(BaseModel): le=1.0, description="Story completion percentage (completed_beats / total_beats)", ) - last_updated: datetime = Field(default_factory=datetime.utcnow) + last_updated: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) # ============================================================================= 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 a171120..9b58470 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -1001,6 +1001,17 @@ def mongodb_update_story_outline( - Updating mystery structure - Marking clues as discovered + Beat operations are applied in this order: + 1. update_beats: Modify existing beats (preserves order) + 2. remove_beat_ids: Remove beats by ID + 3. add_beats: Append new beats to the end + 4. reorder_beats: Reorder all beats (must include ALL beat IDs) + + Note: reorder_beats requires all beat IDs to be included. Mixing + reorder_beats with other beat operations in the same update may lead + to unexpected results. It's recommended to either use reorder_beats + alone or use other beat operations separately. + Args: story_id: Story UUID params: Update parameters @@ -1039,12 +1050,11 @@ def mongodb_update_story_outline( # Update existing beats if params.update_beats: - beats_by_id = {str(b.beat_id): b for b in current_beats} - for updated_beat in params.update_beats: - beat_id_str = str(updated_beat.beat_id) - if beat_id_str in beats_by_id: - beats_by_id[beat_id_str] = updated_beat - current_beats = list(beats_by_id.values()) + update_map = {str(b.beat_id): b for b in params.update_beats} + for i, beat in enumerate(current_beats): + beat_id_str = str(beat.beat_id) + if beat_id_str in update_map: + current_beats[i] = update_map[beat_id_str] # Remove beats if params.remove_beat_ids: @@ -1058,13 +1068,19 @@ def mongodb_update_story_outline( # Reorder beats if params.reorder_beats: beats_by_id = {str(b.beat_id): b for b in current_beats} + if len(params.reorder_beats) != len(beats_by_id): + raise ValueError( + f"reorder_beats must include all {len(beats_by_id)} beat IDs. " + f"Got {len(params.reorder_beats)} IDs instead." + ) reordered: list[StoryBeat] = [] for beat_id in params.reorder_beats: beat_id_str = str(beat_id) - if beat_id_str in beats_by_id: - beat = beats_by_id[beat_id_str] - beat.order = len(reordered) - reordered.append(beat) + if beat_id_str not in beats_by_id: + raise ValueError(f"Beat ID {beat_id} not found in current beats") + beat = beats_by_id[beat_id_str] + beat.order = len(reordered) + reordered.append(beat) current_beats = reordered update_doc["beats"] = [beat.model_dump(mode="json") for beat in current_beats] @@ -1084,6 +1100,10 @@ def mongodb_update_story_outline( # Mark clue as discovered if params.mark_clue_discovered and "mystery_structure" in doc: mystery = doc["mystery_structure"] + if mystery is None: + raise ValueError( + "Cannot mark clue as discovered: story outline has no mystery structure" + ) clue_id_str = str(params.mark_clue_discovered) now = datetime.now(timezone.utc) 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 2073fb6..2d20f86 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -2517,7 +2517,7 @@ def neo4j_get_plot_thread(id: UUID) -> Optional[PlotThreadResponse]: MATCH (t:PlotThread {id: $id}) OPTIONAL MATCH (t)-[:ADVANCED_BY]->(sc:Scene) OPTIONAL MATCH (t)-[:INVOLVES]->(e) - WHERE e:EntityArchetype OR e:EntityInstance + WHERE (e:EntityArchetype OR e:EntityInstance) OPTIONAL MATCH (fe:Event)-[:FORESHADOWS]->(t) OPTIONAL MATCH (re:Event)-[:REVEALS]->(t) RETURN t, diff --git a/packages/data-layer/tests/test_tools/test_story_outline_tools.py b/packages/data-layer/tests/test_tools/test_story_outline_tools.py index c6107c9..c443122 100644 --- a/packages/data-layer/tests/test_tools/test_story_outline_tools.py +++ b/packages/data-layer/tests/test_tools/test_story_outline_tools.py @@ -593,3 +593,74 @@ def test_update_story_outline_mark_clue_discovered( assert ( result.mystery_structure.core_clues[0].visibility == ClueVisibility.DISCOVERED ) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_mark_clue_discovered_no_mystery_structure( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test marking a clue as discovered fails when no mystery structure exists.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + # Outline without mystery structure + outline_no_mystery = story_outline_data.copy() + outline_no_mystery["mystery_structure"] = None + + mock_collection.find_one.return_value = outline_no_mystery + + clue_id = uuid4() + params = StoryOutlineUpdate( + mark_clue_discovered=clue_id, + ) + + # Should raise ValueError because mystery_structure is None + with pytest.raises(ValueError, match="no mystery structure"): + mongodb_update_story_outline(UUID(story_data["id"]), params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_reorder_beats_incomplete( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], + story_beat_data: Dict[str, Any], +): + """Test reorder_beats validation requires all beat IDs.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + # Create outline with 3 beats + beat1 = story_beat_data.copy() + beat1["beat_id"] = str(uuid4()) + beat1["order"] = 0 + + beat2 = story_beat_data.copy() + beat2["beat_id"] = str(uuid4()) + beat2["order"] = 1 + beat2["title"] = "Middle Scene" + + beat3 = story_beat_data.copy() + beat3["beat_id"] = str(uuid4()) + beat3["order"] = 2 + beat3["title"] = "Finale" + + outline_with_beats = story_outline_data.copy() + outline_with_beats["beats"] = [beat1, beat2, beat3] + + mock_collection.find_one.return_value = outline_with_beats + + # Try to reorder with only 2 beat IDs (missing one) + params = StoryOutlineUpdate( + reorder_beats=[UUID(beat1["beat_id"]), UUID(beat2["beat_id"])] # Missing beat3! + ) + + # Should raise ValueError because not all beat IDs are included + with pytest.raises(ValueError, match="must include all.*beat IDs"): + mongodb_update_story_outline(UUID(story_data["id"]), params)