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 6b29198..0477d94 100644 --- a/packages/data-layer/src/monitor_data/schemas/base.py +++ b/packages/data-layer/src/monitor_data/schemas/base.py @@ -132,6 +132,89 @@ class Speaker(str, Enum): ENTITY = "entity" +class PlotThreadType(str, Enum): + """Type of plot thread.""" + + MAIN = "main" + SIDE = "side" + CHARACTER = "character" + MYSTERY = "mystery" + + +class PlotThreadStatus(str, Enum): + """Status of plot thread progression.""" + + OPEN = "open" + ADVANCED = "advanced" + RESOLVED = "resolved" + ABANDONED = "abandoned" + + +class BeatStatus(str, Enum): + """Status of story beat.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + SKIPPED = "skipped" + + +class StoryStructureType(str, Enum): + """Narrative structure type.""" + + LINEAR = "linear" + BRANCHING = "branching" + OPEN_WORLD = "open_world" + + +class ArcTemplate(str, Enum): + """Story arc template.""" + + THREE_ACT = "three_act" + HEIST = "heist" + MYSTERY = "mystery" + JOURNEY = "journey" + SIEGE = "siege" + POLITICAL = "political" + DUNGEON = "dungeon" + CUSTOM = "custom" + + +class ThreadPriority(str, Enum): + """Plot thread priority level.""" + + MAIN = "main" + MAJOR = "major" + MINOR = "minor" + BACKGROUND = "background" + + +class ThreadUrgency(str, Enum): + """Plot thread urgency level.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class ClueVisibility(str, Enum): + """Visibility status for mystery clues.""" + + HIDDEN = "hidden" + DISCOVERED = "discovered" + REVEALED = "revealed" + + +class PayoffStatus(str, Enum): + """Foreshadowing payoff status.""" + + SETUP_ONLY = "setup_only" + PARTIAL_PAYOFF = "partial_payoff" + FULL_PAYOFF = "full_payoff" + ABANDONED = "abandoned" + + # ============================================================================= # BASE MODELS # ============================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/story_outlines.py b/packages/data-layer/src/monitor_data/schemas/story_outlines.py new file mode 100644 index 0000000..4172075 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/story_outlines.py @@ -0,0 +1,374 @@ +""" +Pydantic schemas for Story Outlines and Plot Threads (DL-6). + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: mongodb_tools.py, neo4j_tools.py + +These schemas define the data contracts for narrative structure tracking: +- Story Outlines (MongoDB): Planning, beats, pacing, mysteries +- Plot Threads (Neo4j): Cross-scene narrative threads with relationships + +DL-6: Comprehensive implementation with narrative engine support +""" + +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import ( + PlotThreadType, + PlotThreadStatus, + BeatStatus, + StoryStructureType, + ArcTemplate, + ThreadPriority, + ThreadUrgency, + ClueVisibility, + PayoffStatus, +) + + +# ============================================================================= +# STORY BEAT SCHEMAS (MongoDB - nested in story_outline) +# ============================================================================= + + +class StoryBeat(BaseModel): + """Individual story beat with progression tracking.""" + + beat_id: UUID = Field(default_factory=uuid4) + title: str = Field(min_length=1, max_length=200) + 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( + default=False, description="Can be skipped without story incompleteness" + ) + related_threads: List[UUID] = Field( + default_factory=list, + description="PlotThread IDs that advance during this beat", + ) + required_for_threads: List[UUID] = Field( + default_factory=list, + description="PlotThreads that must be active for this beat to trigger", + ) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + started_at: Optional[datetime] = Field( + None, description="When status changed to in_progress" + ) + completed_at: Optional[datetime] = Field( + None, description="When status changed to completed" + ) + completed_in_scene_id: Optional[UUID] = Field( + None, description="Scene that completed this beat" + ) + + +class BranchingPoint(BaseModel): + """Decision point for branching narratives.""" + + beat_id: UUID = Field(description="Beat where branching occurs") + decision: str = Field(max_length=500, description="What choice is made") + branches: List[Dict[str, Any]] = Field( + default_factory=list, + description="Possible outcomes with conditions and next beats", + ) + + +# ============================================================================= +# MYSTERY STRUCTURE SCHEMAS (MongoDB - nested in story_outline) +# ============================================================================= + + +class MysteryClue(BaseModel): + """Clue in a mystery structure.""" + + clue_id: UUID = Field( + default_factory=uuid4, description="References Neo4j Fact node" + ) + content: str = Field(max_length=1000) + discovery_methods: List[str] = Field( + default_factory=list, description="How players can discover this clue" + ) + is_discovered: bool = Field(default=False) + discovered_in_scene_id: Optional[UUID] = None + discovered_at: Optional[datetime] = None + points_to: str = Field(default="", description="Which theory/suspect it supports") + visibility: ClueVisibility = Field( + default=ClueVisibility.HIDDEN, + description="Current visibility status", + ) + + +class MysterySuspect(BaseModel): + """Suspect/theory in a mystery.""" + + entity_id: UUID = Field(description="Entity being suspected") + theory: str = Field(max_length=500) + evidence_for: List[UUID] = Field( + default_factory=list, description="Clue IDs supporting this theory" + ) + evidence_against: List[UUID] = Field( + default_factory=list, description="Clue IDs contradicting this theory" + ) + + +class MysteryStructure(BaseModel): + """Mystery-specific story structure.""" + + truth: str = Field(max_length=2000, description="GM secret - actual solution") + question: str = Field(max_length=500, description="What players are solving") + core_clues: List[MysteryClue] = Field( + default_factory=list, description="Essential clues for solving" + ) + bonus_clues: List[MysteryClue] = Field( + default_factory=list, description="Additional supporting clues" + ) + red_herrings: List[MysteryClue] = Field( + default_factory=list, description="Misleading clues" + ) + suspects: List[MysterySuspect] = Field(default_factory=list) + current_player_theories: List[str] = Field( + default_factory=list, description="What players currently think" + ) + + +# ============================================================================= +# PACING METRICS SCHEMAS (MongoDB - nested in story_outline) +# ============================================================================= + + +class PacingMetrics(BaseModel): + """Narrative pacing and tension tracking.""" + + current_act: int = Field(default=1, ge=1, le=5, description="Current story act") + tension_level: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description="Current narrative tension (0=calm, 1=climax)", + ) + scenes_since_major_event: int = Field( + default=0, ge=0, description="Scenes since last significant plot event" + ) + scenes_in_current_act: int = Field(default=0, ge=0) + estimated_completion: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description="Story completion percentage (completed_beats / total_beats)", + ) + last_updated: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +# ============================================================================= +# STORY OUTLINE SCHEMAS (MongoDB) +# ============================================================================= + + +class StoryOutlineCreate(BaseModel): + """Create story outline with comprehensive narrative tracking.""" + + story_id: UUID + theme: str = Field(default="", max_length=500) + premise: str = Field(default="", max_length=2000) + constraints: List[str] = Field( + default_factory=list, description="Story constraints or rules" + ) + beats: List[StoryBeat] = Field(default_factory=list) + structure_type: StoryStructureType = Field(default=StoryStructureType.LINEAR) + template: ArcTemplate = Field(default=ArcTemplate.CUSTOM) + branching_points: List[BranchingPoint] = Field( + default_factory=list, description="Only for branching narratives" + ) + mystery_structure: Optional[MysteryStructure] = Field( + None, description="Only for mystery stories" + ) + + +class StoryOutlineUpdate(BaseModel): + """Partial update to story outline with beat manipulation.""" + + theme: Optional[str] = Field(None, max_length=500) + premise: Optional[str] = Field(None, max_length=2000) + constraints: Optional[List[str]] = None + structure_type: Optional[StoryStructureType] = None + template: Optional[ArcTemplate] = None + # Beat operations (partial updates) + add_beats: Optional[List[StoryBeat]] = Field( + None, description="New beats to add to the end or insert" + ) + remove_beat_ids: Optional[List[UUID]] = Field( + None, description="Beat IDs to remove" + ) + reorder_beats: Optional[List[UUID]] = Field( + None, description="Reorder beats by providing full ordered list of beat_ids" + ) + update_beats: Optional[List[StoryBeat]] = Field( + None, description="Update existing beats (matched by beat_id)" + ) + # Mystery operations + update_mystery_structure: Optional[MysteryStructure] = None + mark_clue_discovered: Optional[UUID] = Field( + None, description="Clue ID to mark as discovered" + ) + # Branching operations + add_branching_points: Optional[List[BranchingPoint]] = None + + +class StoryOutlineResponse(BaseModel): + """Story outline response with computed fields.""" + + story_id: UUID + theme: str + premise: str + constraints: List[str] + beats: List[StoryBeat] + structure_type: StoryStructureType + template: ArcTemplate + branching_points: List[BranchingPoint] + mystery_structure: Optional[MysteryStructure] = None + pacing_metrics: PacingMetrics + open_threads: List[str] = Field( + default_factory=list, + description="List of unresolved thread titles (computed)", + ) + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +# ============================================================================= +# PLOT THREAD SCHEMAS (Neo4j) +# ============================================================================= + + +class ThreadDeadline(BaseModel): + """Time pressure for a plot thread.""" + + world_time: datetime = Field(description="In-game deadline") + description: str = Field(max_length=500) + + +class PlotThreadCreate(BaseModel): + """Create plot thread with relationships and narrative tracking.""" + + story_id: UUID + title: str = Field(min_length=1, max_length=200) + thread_type: PlotThreadType + status: PlotThreadStatus = Field(default=PlotThreadStatus.OPEN) + priority: ThreadPriority = Field( + default=ThreadPriority.MINOR, description="Narrative importance" + ) + urgency: ThreadUrgency = Field(default=ThreadUrgency.LOW) + deadline: Optional[ThreadDeadline] = None + # Relationships (created during thread creation) + scene_ids: List[UUID] = Field( + default_factory=list, + description="Scenes that advanced this thread (ADVANCED_BY)", + ) + entity_ids: List[UUID] = Field( + default_factory=list, description="Entities involved in this thread (INVOLVES)" + ) + # Foreshadowing/payoff tracking + foreshadowing_events: List[UUID] = Field( + default_factory=list, + description="Event IDs that set up this thread (FORESHADOWS)", + ) + revelation_events: List[UUID] = Field( + default_factory=list, description="Event IDs that pay off this thread (REVEALS)" + ) + payoff_status: PayoffStatus = Field(default=PayoffStatus.SETUP_ONLY) + # Engagement tracking + player_interest_level: float = Field( + default=0.5, + ge=0.0, + le=1.0, + description="Tracked from player engagement", + ) + gm_importance: float = Field(default=0.5, ge=0.0, le=1.0, description="Set by GM") + + +class PlotThreadUpdate(BaseModel): + """Update plot thread with relationship modifications.""" + + title: Optional[str] = Field(None, min_length=1, max_length=200) + status: Optional[PlotThreadStatus] = None + priority: Optional[ThreadPriority] = None + urgency: Optional[ThreadUrgency] = None + deadline: Optional[ThreadDeadline] = None + payoff_status: Optional[PayoffStatus] = None + player_interest_level: Optional[float] = Field(None, ge=0.0, le=1.0) + gm_importance: Optional[float] = Field(None, ge=0.0, le=1.0) + # Relationship operations (additive only - no removal to preserve history) + add_scene_ids: Optional[List[UUID]] = Field( + None, description="Add scenes that advanced this thread" + ) + add_entity_ids: Optional[List[UUID]] = Field( + None, description="Add entities involved in this thread" + ) + add_foreshadowing_events: Optional[List[UUID]] = None + add_revelation_events: Optional[List[UUID]] = None + + +class PlotThreadResponse(BaseModel): + """Plot thread response with all tracking data.""" + + id: UUID + story_id: UUID + title: str + thread_type: PlotThreadType + status: PlotThreadStatus + priority: ThreadPriority + urgency: ThreadUrgency + deadline: Optional[ThreadDeadline] = None + payoff_status: PayoffStatus + player_interest_level: float + gm_importance: float + # Relationships (lists of UUIDs) + scene_ids: List[UUID] = Field(default_factory=list) + entity_ids: List[UUID] = Field(default_factory=list) + foreshadowing_events: List[UUID] = Field(default_factory=list) + revelation_events: List[UUID] = Field(default_factory=list) + # Timestamps + created_at: datetime + updated_at: datetime + resolved_at: Optional[datetime] = Field( + None, description="When status changed to resolved" + ) + + model_config = {"from_attributes": True} + + +class PlotThreadFilter(BaseModel): + """Filter for listing plot threads.""" + + story_id: Optional[UUID] = None + thread_type: Optional[PlotThreadType] = None + status: Optional[PlotThreadStatus] = None + priority: Optional[ThreadPriority] = None + entity_id: Optional[UUID] = Field( + None, description="Show threads involving this entity" + ) + limit: int = Field(default=50, ge=1, le=1000) + offset: int = Field(default=0, ge=0) + sort_by: str = Field( + default="created_at", + description="Sort field: created_at, updated_at, priority, urgency", + ) + sort_order: str = Field( + default="desc", description="Sort order: asc, desc", pattern="^(asc|desc)$" + ) + + +class PlotThreadListResponse(BaseModel): + """Response with list of plot threads and pagination.""" + + threads: List[PlotThreadResponse] + 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 index a12fc8d..9b58470 100644 --- a/packages/data-layer/src/monitor_data/tools/mongodb_tools.py +++ b/packages/data-layer/src/monitor_data/tools/mongodb_tools.py @@ -34,6 +34,17 @@ DecisionMetadata, ) from monitor_data.schemas.base import SceneStatus, ProposalStatus +from monitor_data.schemas.story_outlines import ( + StoryOutlineCreate, + StoryOutlineUpdate, + StoryOutlineResponse, + StoryBeat, + PacingMetrics, + BranchingPoint, + MysteryStructure, + MysteryClue, + BeatStatus, +) # ============================================================================= @@ -753,3 +764,373 @@ def mongodb_update_proposed_change( raise ValueError(f"Proposal {proposal_id} not found after update") return updated_proposal + + +# ============================================================================= +# STORY OUTLINE OPERATIONS (DL-6) +# ============================================================================= + + +def _convert_story_outline_doc_to_response(doc: Dict[str, Any]) -> StoryOutlineResponse: + """ + Convert a story_outline document from MongoDB to StoryOutlineResponse. + + Args: + doc: Story outline document from MongoDB + + Returns: + StoryOutlineResponse object + """ + # Convert beats + beats = [] + for beat_dict in doc.get("beats", []): + beat = StoryBeat( + beat_id=UUID(beat_dict["beat_id"]), + title=beat_dict["title"], + description=beat_dict["description"], + order=beat_dict["order"], + status=BeatStatus(beat_dict.get("status", "pending")), + optional=beat_dict.get("optional", False), + related_threads=[UUID(tid) for tid in beat_dict.get("related_threads", [])], + required_for_threads=[ + UUID(tid) for tid in beat_dict.get("required_for_threads", []) + ], + created_at=beat_dict.get("created_at"), + started_at=beat_dict.get("started_at"), + completed_at=beat_dict.get("completed_at"), + completed_in_scene_id=( + UUID(beat_dict["completed_in_scene_id"]) + if beat_dict.get("completed_in_scene_id") + else None + ), + ) + beats.append(beat) + + # Convert pacing metrics + pacing_dict = doc.get("pacing_metrics", {}) + pacing = PacingMetrics(**pacing_dict) if pacing_dict else PacingMetrics() + + # Convert mystery structure if present + mystery_structure = None + if "mystery_structure" in doc and doc["mystery_structure"]: + mystery_dict = doc["mystery_structure"] + mystery_structure = MysteryStructure( + truth=mystery_dict["truth"], + question=mystery_dict["question"], + core_clues=[ + MysteryClue(**clue) for clue in mystery_dict.get("core_clues", []) + ], + bonus_clues=[ + MysteryClue(**clue) for clue in mystery_dict.get("bonus_clues", []) + ], + red_herrings=[ + MysteryClue(**clue) for clue in mystery_dict.get("red_herrings", []) + ], + suspects=mystery_dict.get("suspects", []), + current_player_theories=mystery_dict.get("current_player_theories", []), + ) + + # Convert branching points + branching_points = [BranchingPoint(**bp) for bp in doc.get("branching_points", [])] + + return StoryOutlineResponse( + story_id=UUID(doc["story_id"]), + theme=doc.get("theme", ""), + premise=doc.get("premise", ""), + constraints=doc.get("constraints", []), + beats=beats, + structure_type=doc.get("structure_type", "linear"), + template=doc.get("template", "custom"), + branching_points=branching_points, + mystery_structure=mystery_structure, + pacing_metrics=pacing, + open_threads=doc.get("open_threads", []), + created_at=doc["created_at"], + updated_at=doc["updated_at"], + ) + + +def _calculate_pacing_metrics( + beats: list[StoryBeat], scenes_since_major_event: int = 0 +) -> PacingMetrics: + """ + Calculate pacing metrics from beats. + + Args: + beats: List of story beats + scenes_since_major_event: Counter for pacing + + Returns: + Calculated pacing metrics + """ + total_beats = len(beats) + completed_beats = sum(1 for b in beats if b.status == BeatStatus.COMPLETED) + in_progress_beats = sum(1 for b in beats if b.status == BeatStatus.IN_PROGRESS) + + # Calculate completion percentage + estimated_completion = completed_beats / total_beats if total_beats > 0 else 0.0 + + # Calculate tension based on active threads and progression + # Simple heuristic: tension rises as we approach climax (80%+) or have many in-progress beats + tension_level = 0.0 + if estimated_completion > 0.8: + tension_level = min(1.0, 0.7 + (estimated_completion - 0.8) * 1.5) + elif in_progress_beats > 0: + tension_level = min(0.6, 0.3 + (in_progress_beats * 0.1)) + + # Determine current act (simple three-act structure) + if estimated_completion < 0.25: + current_act = 1 + elif estimated_completion < 0.75: + current_act = 2 + else: + current_act = 3 + + return PacingMetrics( + current_act=current_act, + tension_level=tension_level, + scenes_since_major_event=scenes_since_major_event, + scenes_in_current_act=0, # Would need scene tracking + estimated_completion=estimated_completion, + last_updated=datetime.now(timezone.utc), + ) + + +def mongodb_create_story_outline(params: StoryOutlineCreate) -> StoryOutlineResponse: + """ + Create a story outline for a story (DL-6). + + Authority: Orchestrator + Use Case: P-1, ST-1 + + Args: + params: Story outline creation parameters + + Returns: + Created story outline + + Raises: + ValueError: If story doesn't exist in Neo4j + """ + client = get_mongodb_client() + neo4j_client = get_neo4j_client() + outlines_collection = client.get_collection("story_outlines") + + # Verify story exists in Neo4j + verify_query = "MATCH (s:Story {id: $story_id}) RETURN s.id as id" + result = neo4j_client.execute_read(verify_query, {"story_id": str(params.story_id)}) + if not result: + raise ValueError(f"Story {params.story_id} not found") + + # Check if outline already exists + existing = outlines_collection.find_one({"story_id": str(params.story_id)}) + if existing: + raise ValueError(f"Story outline for {params.story_id} already exists") + + # Calculate initial pacing metrics + pacing = _calculate_pacing_metrics(params.beats) + + # Build document + now = datetime.now(timezone.utc) + doc = { + "story_id": str(params.story_id), + "theme": params.theme, + "premise": params.premise, + "constraints": params.constraints, + "beats": [beat.model_dump(mode="json") for beat in params.beats], + "structure_type": params.structure_type.value, + "template": params.template.value, + "branching_points": [ + bp.model_dump(mode="json") for bp in params.branching_points + ], + "mystery_structure": ( + params.mystery_structure.model_dump(mode="json") + if params.mystery_structure + else None + ), + "pacing_metrics": pacing.model_dump(mode="json"), + "open_threads": [], # Will be populated by plot threads + "created_at": now, + "updated_at": now, + } + + # Insert + outlines_collection.insert_one(doc) + + return _convert_story_outline_doc_to_response(doc) + + +def mongodb_get_story_outline(story_id: UUID) -> Optional[StoryOutlineResponse]: + """ + Get story outline for a story (DL-6). + + Authority: All agents + Use Case: P-2, ST-1 + + Args: + story_id: Story UUID + + Returns: + Story outline or None if not found + """ + client = get_mongodb_client() + outlines_collection = client.get_collection("story_outlines") + + doc = outlines_collection.find_one({"story_id": str(story_id)}) + if not doc: + return None + + return _convert_story_outline_doc_to_response(doc) + + +def mongodb_update_story_outline( + story_id: UUID, params: StoryOutlineUpdate +) -> StoryOutlineResponse: + """ + Update story outline with partial updates and beat manipulation (DL-6). + + Authority: Orchestrator + Use Case: P-8, ST-1 + + Supports: + - Updating theme, premise, constraints, structure, template + - Adding new beats + - Removing beats by ID + - Reordering beats + - Updating existing beats + - 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 + + Returns: + Updated story outline + + Raises: + ValueError: If outline doesn't exist or beat operations invalid + """ + client = get_mongodb_client() + outlines_collection = client.get_collection("story_outlines") + + # Get existing document + doc = outlines_collection.find_one({"story_id": str(story_id)}) + if not doc: + raise ValueError(f"Story outline for {story_id} not found") + + # Build update document + update_doc: Dict[str, Any] = {"updated_at": datetime.now(timezone.utc)} + + # Update simple fields + if params.theme is not None: + update_doc["theme"] = params.theme + if params.premise is not None: + update_doc["premise"] = params.premise + if params.constraints is not None: + update_doc["constraints"] = params.constraints + if params.structure_type is not None: + update_doc["structure_type"] = params.structure_type.value + if params.template is not None: + update_doc["template"] = params.template.value + + # Handle beat operations + current_beats = [StoryBeat(**b) for b in doc.get("beats", [])] + + # Update existing beats + if params.update_beats: + 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: + remove_ids = {str(bid) for bid in params.remove_beat_ids} + current_beats = [b for b in current_beats if str(b.beat_id) not in remove_ids] + + # Add beats + if params.add_beats: + current_beats.extend(params.add_beats) + + # 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 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] + + # Recalculate pacing metrics + pacing = _calculate_pacing_metrics( + current_beats, doc.get("pacing_metrics", {}).get("scenes_since_major_event", 0) + ) + update_doc["pacing_metrics"] = pacing.model_dump(mode="json") + + # Update mystery structure + if params.update_mystery_structure: + update_doc["mystery_structure"] = params.update_mystery_structure.model_dump( + mode="json" + ) + + # 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) + + # Search in all clue lists + for clue_list_name in ["core_clues", "bonus_clues", "red_herrings"]: + for clue in mystery.get(clue_list_name, []): + if str(clue.get("clue_id")) == clue_id_str: + clue["is_discovered"] = True + clue["discovered_at"] = now + clue["visibility"] = "discovered" + + update_doc["mystery_structure"] = mystery + + # Branching points + if params.add_branching_points: + existing_bp = doc.get("branching_points", []) + existing_bp.extend( + [bp.model_dump(mode="json") for bp in params.add_branching_points] + ) + update_doc["branching_points"] = existing_bp + + # Perform update + outlines_collection.update_one({"story_id": str(story_id)}, {"$set": update_doc}) + + # Return updated outline + updated = mongodb_get_story_outline(story_id) + if not updated: + raise ValueError(f"Story outline {story_id} not found after update") + + return updated 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 a689178..2d20f86 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -47,6 +47,15 @@ StoryFilter, StoryListResponse, ) +from monitor_data.schemas.story_outlines import ( + PlotThreadCreate, + PlotThreadUpdate, + PlotThreadResponse, + PlotThreadFilter, + PlotThreadListResponse, + PlotThreadStatus, + ThreadDeadline, +) # ============================================================================= @@ -2345,3 +2354,522 @@ def neo4j_list_stories(params: StoryFilter) -> StoryListResponse: return StoryListResponse( stories=stories, total=total, limit=params.limit, offset=params.offset ) + + +# ============================================================================= +# PLOT THREAD OPERATIONS (DL-6) +# ============================================================================= + + +def neo4j_create_plot_thread(params: PlotThreadCreate) -> PlotThreadResponse: + """ + Create a plot thread with relationships (DL-6). + + Creates PlotThread node and relationships: + - (:Story)-[:HAS_THREAD]->(:PlotThread) + - (:PlotThread)-[:ADVANCED_BY]->(:Scene) for each scene_id + - (:PlotThread)-[:INVOLVES]->(:EntityInstance) for each entity_id + - (:Event)-[:FORESHADOWS]->(:PlotThread) for each foreshadowing_event + - (:Event)-[:REVEALS]->(:PlotThread) for each revelation_event + + Authority: CanonKeeper + Use Case: P-1, ST-1 + + Args: + params: Plot thread creation parameters + + Returns: + Created plot thread + + Raises: + ValueError: If story doesn't exist or referenced nodes not found + """ + client = get_neo4j_client() + + # Verify story exists + verify_query = "MATCH (s:Story {id: $story_id}) RETURN s.id as id" + result = client.execute_read(verify_query, {"story_id": str(params.story_id)}) + if not result: + raise ValueError(f"Story {params.story_id} not found") + + # Generate ID and prepare data + thread_id = uuid4() + now = datetime.now(timezone.utc) + + # Build create query with relationships + create_query = """ + MATCH (s:Story {id: $story_id}) + CREATE (t:PlotThread { + id: $id, + story_id: $story_id, + title: $title, + thread_type: $thread_type, + status: $status, + priority: $priority, + urgency: $urgency, + deadline: $deadline, + payoff_status: $payoff_status, + player_interest_level: $player_interest_level, + gm_importance: $gm_importance, + created_at: datetime($created_at), + updated_at: datetime($updated_at), + resolved_at: $resolved_at + }) + CREATE (s)-[:HAS_THREAD]->(t) + RETURN t + """ + + query_params = { + "id": str(thread_id), + "story_id": str(params.story_id), + "title": params.title, + "thread_type": params.thread_type.value, + "status": params.status.value, + "priority": params.priority.value, + "urgency": params.urgency.value, + "deadline": ( + params.deadline.model_dump(mode="json") if params.deadline else None + ), + "payoff_status": params.payoff_status.value, + "player_interest_level": params.player_interest_level, + "gm_importance": params.gm_importance, + "created_at": now.isoformat(), + "updated_at": now.isoformat(), + "resolved_at": None, + } + + # Create node + client.execute_write(create_query, query_params) + + # Create ADVANCED_BY relationships to scenes + if params.scene_ids: + for scene_id in params.scene_ids: + scene_rel_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (sc:Scene {id: $scene_id}) + MERGE (t)-[:ADVANCED_BY]->(sc) + """ + client.execute_write( + scene_rel_query, + {"thread_id": str(thread_id), "scene_id": str(scene_id)}, + ) + + # Create INVOLVES relationships to entities + if params.entity_ids: + for entity_id in params.entity_ids: + entity_rel_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + MERGE (t)-[:INVOLVES]->(e) + """ + client.execute_write( + entity_rel_query, + {"thread_id": str(thread_id), "entity_id": str(entity_id)}, + ) + + # Create FORESHADOWS relationships + if params.foreshadowing_events: + for event_id in params.foreshadowing_events: + foreshadow_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e:Event {id: $event_id}) + MERGE (e)-[:FORESHADOWS]->(t) + """ + client.execute_write( + foreshadow_query, + {"thread_id": str(thread_id), "event_id": str(event_id)}, + ) + + # Create REVEALS relationships + if params.revelation_events: + for event_id in params.revelation_events: + reveal_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e:Event {id: $event_id}) + MERGE (e)-[:REVEALS]->(t) + """ + client.execute_write( + reveal_query, + {"thread_id": str(thread_id), "event_id": str(event_id)}, + ) + + # Return the created thread + return neo4j_get_plot_thread(thread_id) # type: ignore + + +def neo4j_get_plot_thread(id: UUID) -> Optional[PlotThreadResponse]: + """ + Get a plot thread by ID with all relationships (DL-6). + + Authority: All agents + Use Case: ST-1, CF-3 + + Args: + id: Plot thread UUID + + Returns: + Plot thread or None if not found + """ + client = get_neo4j_client() + + query = """ + MATCH (t:PlotThread {id: $id}) + OPTIONAL MATCH (t)-[:ADVANCED_BY]->(sc:Scene) + OPTIONAL MATCH (t)-[:INVOLVES]->(e) + WHERE (e:EntityArchetype OR e:EntityInstance) + OPTIONAL MATCH (fe:Event)-[:FORESHADOWS]->(t) + OPTIONAL MATCH (re:Event)-[:REVEALS]->(t) + RETURN t, + collect(DISTINCT sc.id) as scene_ids, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT fe.id) as foreshadowing_event_ids, + collect(DISTINCT re.id) as revelation_event_ids + """ + + results = client.execute_read(query, {"id": str(id)}) + if not results: + return None + + record = results[0] + t = record["t"] + scene_ids = [UUID(sid) for sid in record["scene_ids"] if sid] + entity_ids = [UUID(eid) for eid in record["entity_ids"] if eid] + foreshadowing_events = [ + UUID(fid) for fid in record["foreshadowing_event_ids"] if fid + ] + revelation_events = [UUID(rid) for rid in record["revelation_event_ids"] if rid] + + # Parse deadline if present + deadline = None + if t.get("deadline"): + deadline_data = t["deadline"] + deadline = ThreadDeadline( + world_time=deadline_data["world_time"], + description=deadline_data["description"], + ) + + return PlotThreadResponse( + id=UUID(t["id"]), + story_id=UUID(t["story_id"]), + title=t["title"], + thread_type=t["thread_type"], + status=t["status"], + priority=t["priority"], + urgency=t["urgency"], + deadline=deadline, + payoff_status=t["payoff_status"], + player_interest_level=t["player_interest_level"], + gm_importance=t["gm_importance"], + scene_ids=scene_ids, + entity_ids=entity_ids, + foreshadowing_events=foreshadowing_events, + revelation_events=revelation_events, + created_at=t["created_at"], + updated_at=t["updated_at"], + resolved_at=t.get("resolved_at"), + ) + + +def neo4j_update_plot_thread(id: UUID, params: PlotThreadUpdate) -> PlotThreadResponse: + """ + Update a plot thread and add relationships (DL-6). + + Note: Relationships are additive only (no removal) to preserve history. + Status transitions are validated. + + Authority: CanonKeeper + Use Case: P-8, ST-1, CF-3 + + Args: + id: Plot thread UUID + params: Update parameters + + Returns: + Updated plot thread + + Raises: + ValueError: If thread not found or invalid status transition + """ + client = get_neo4j_client() + + # Get existing thread + existing = neo4j_get_plot_thread(id) + if not existing: + raise ValueError(f"Plot thread {id} not found") + + # Validate status transition if status is being updated + if params.status: + current_status = PlotThreadStatus(existing.status) + new_status = params.status + + # Valid transitions + valid_transitions = { + PlotThreadStatus.OPEN: [ + PlotThreadStatus.ADVANCED, + PlotThreadStatus.RESOLVED, + PlotThreadStatus.ABANDONED, + ], + PlotThreadStatus.ADVANCED: [ + PlotThreadStatus.RESOLVED, + PlotThreadStatus.ABANDONED, + ], + PlotThreadStatus.RESOLVED: [], + PlotThreadStatus.ABANDONED: [], + } + + if new_status not in valid_transitions[current_status]: + raise ValueError( + f"Invalid status transition from {current_status.value} to {new_status.value}" + ) + + # Build update query + update_parts = ["t.updated_at = datetime($updated_at)"] + query_params: Dict[str, Any] = { + "id": str(id), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + + if params.title is not None: + update_parts.append("t.title = $title") + query_params["title"] = params.title + + if params.status is not None: + update_parts.append("t.status = $status") + query_params["status"] = params.status.value + # Set resolved_at if transitioning to resolved + if params.status == PlotThreadStatus.RESOLVED: + update_parts.append("t.resolved_at = datetime($resolved_at)") + query_params["resolved_at"] = datetime.now(timezone.utc).isoformat() + + if params.priority is not None: + update_parts.append("t.priority = $priority") + query_params["priority"] = params.priority.value + + if params.urgency is not None: + update_parts.append("t.urgency = $urgency") + query_params["urgency"] = params.urgency.value + + if params.deadline is not None: + update_parts.append("t.deadline = $deadline") + query_params["deadline"] = params.deadline.model_dump(mode="json") + + if params.payoff_status is not None: + update_parts.append("t.payoff_status = $payoff_status") + query_params["payoff_status"] = params.payoff_status.value + + if params.player_interest_level is not None: + update_parts.append("t.player_interest_level = $player_interest_level") + query_params["player_interest_level"] = params.player_interest_level + + if params.gm_importance is not None: + update_parts.append("t.gm_importance = $gm_importance") + query_params["gm_importance"] = params.gm_importance + + # Update node properties + update_query = f""" + MATCH (t:PlotThread {{id: $id}}) + SET {', '.join(update_parts)} + RETURN t + """ + client.execute_write(update_query, query_params) + + # Add new scene relationships + if params.add_scene_ids: + for scene_id in params.add_scene_ids: + scene_rel_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (sc:Scene {id: $scene_id}) + MERGE (t)-[:ADVANCED_BY]->(sc) + """ + client.execute_write( + scene_rel_query, {"thread_id": str(id), "scene_id": str(scene_id)} + ) + + # Add new entity relationships + if params.add_entity_ids: + for entity_id in params.add_entity_ids: + entity_rel_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + MERGE (t)-[:INVOLVES]->(e) + """ + client.execute_write( + entity_rel_query, {"thread_id": str(id), "entity_id": str(entity_id)} + ) + + # Add foreshadowing events + if params.add_foreshadowing_events: + for event_id in params.add_foreshadowing_events: + foreshadow_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e:Event {id: $event_id}) + MERGE (e)-[:FORESHADOWS]->(t) + """ + client.execute_write( + foreshadow_query, {"thread_id": str(id), "event_id": str(event_id)} + ) + + # Add revelation events + if params.add_revelation_events: + for event_id in params.add_revelation_events: + reveal_query = """ + MATCH (t:PlotThread {id: $thread_id}) + MATCH (e:Event {id: $event_id}) + MERGE (e)-[:REVEALS]->(t) + """ + client.execute_write( + reveal_query, {"thread_id": str(id), "event_id": str(event_id)} + ) + + # Return updated thread + updated = neo4j_get_plot_thread(id) + if not updated: + raise ValueError(f"Plot thread {id} not found after update") + + return updated + + +def neo4j_list_plot_threads(params: PlotThreadFilter) -> PlotThreadListResponse: + """ + List plot threads with filtering (DL-6). + + Supports filtering by: + - story_id + - thread_type + - status + - priority + - entity_id (threads involving this entity) + + Authority: All agents + Use Case: ST-1, CF-3 + + Args: + params: Filter parameters + + Returns: + List of plot threads with pagination + """ + client = get_neo4j_client() + + # Build WHERE clause + where_clauses = [] + query_params: Dict[str, Any] = {} + + if params.story_id: + where_clauses.append("t.story_id = $story_id") + query_params["story_id"] = str(params.story_id) + + if params.thread_type: + where_clauses.append("t.thread_type = $thread_type") + query_params["thread_type"] = params.thread_type.value + + if params.status: + where_clauses.append("t.status = $status") + query_params["status"] = params.status.value + + if params.priority: + where_clauses.append("t.priority = $priority") + query_params["priority"] = params.priority.value + + # Entity filter requires additional MATCH + entity_match = "" + if params.entity_id: + entity_match = """ + MATCH (t)-[:INVOLVES]->(involved_entity {id: $entity_id}) + """ + query_params["entity_id"] = str(params.entity_id) + + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + # Count total + count_query = f""" + MATCH (t:PlotThread) + {entity_match} + {where_clause} + RETURN count(t) as total + """ + count_result = client.execute_read(count_query, query_params) + total = count_result[0]["total"] if count_result else 0 + + # Determine sort field + sort_field_map = { + "created_at": "t.created_at", + "updated_at": "t.updated_at", + "priority": "t.priority", + "urgency": "t.urgency", + } + sort_field = sort_field_map.get(params.sort_by, "t.created_at") + sort_order = "DESC" if params.sort_order == "desc" else "ASC" + + # List query with relationships + list_query = f""" + MATCH (t:PlotThread) + {entity_match} + {where_clause} + OPTIONAL MATCH (t)-[:ADVANCED_BY]->(sc:Scene) + OPTIONAL MATCH (t)-[:INVOLVES]->(e) + WHERE e:EntityArchetype OR e:EntityInstance + OPTIONAL MATCH (fe:Event)-[:FORESHADOWS]->(t) + OPTIONAL MATCH (re:Event)-[:REVEALS]->(t) + RETURN t, + collect(DISTINCT sc.id) as scene_ids, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT fe.id) as foreshadowing_event_ids, + collect(DISTINCT re.id) as revelation_event_ids + ORDER BY {sort_field} {sort_order} + SKIP $offset + LIMIT $limit + """ + + query_params["offset"] = params.offset + query_params["limit"] = params.limit + + results = client.execute_read(list_query, query_params) + + threads = [] + for record in results: + t = record["t"] + scene_ids = [UUID(sid) for sid in record["scene_ids"] if sid] + entity_ids = [UUID(eid) for eid in record["entity_ids"] if eid] + foreshadowing_events = [ + UUID(fid) for fid in record["foreshadowing_event_ids"] if fid + ] + revelation_events = [UUID(rid) for rid in record["revelation_event_ids"] if rid] + + # Parse deadline if present + deadline = None + if t.get("deadline"): + deadline_data = t["deadline"] + deadline = ThreadDeadline( + world_time=deadline_data["world_time"], + description=deadline_data["description"], + ) + + threads.append( + PlotThreadResponse( + id=UUID(t["id"]), + story_id=UUID(t["story_id"]), + title=t["title"], + thread_type=t["thread_type"], + status=t["status"], + priority=t["priority"], + urgency=t["urgency"], + deadline=deadline, + payoff_status=t["payoff_status"], + player_interest_level=t["player_interest_level"], + gm_importance=t["gm_importance"], + scene_ids=scene_ids, + entity_ids=entity_ids, + foreshadowing_events=foreshadowing_events, + revelation_events=revelation_events, + created_at=t["created_at"], + updated_at=t["updated_at"], + resolved_at=t.get("resolved_at"), + ) + ) + + return PlotThreadListResponse( + threads=threads, total=total, limit=params.limit, offset=params.offset + ) diff --git a/packages/data-layer/tests/test_tools/test_plot_thread_tools.py b/packages/data-layer/tests/test_tools/test_plot_thread_tools.py new file mode 100644 index 0000000..f070f01 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_plot_thread_tools.py @@ -0,0 +1,886 @@ +""" +Unit tests for Neo4j plot thread operations (DL-6). + +Tests cover: +- neo4j_create_plot_thread +- neo4j_get_plot_thread +- neo4j_update_plot_thread +- neo4j_list_plot_threads +""" + +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.story_outlines import ( + PlotThreadCreate, + PlotThreadUpdate, + PlotThreadFilter, + ThreadDeadline, +) +from monitor_data.schemas.base import ( + PlotThreadType, + PlotThreadStatus, + ThreadPriority, + ThreadUrgency, + PayoffStatus, +) +from monitor_data.tools.neo4j_tools import ( + neo4j_create_plot_thread, + neo4j_get_plot_thread, + neo4j_update_plot_thread, + neo4j_list_plot_threads, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def plot_thread_data(story_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample plot thread data.""" + return { + "id": str(uuid4()), + "story_id": story_data["id"], + "title": "The Missing Artifact", + "thread_type": PlotThreadType.MAIN.value, + "status": PlotThreadStatus.OPEN.value, + "priority": ThreadPriority.MAIN.value, + "urgency": ThreadUrgency.MEDIUM.value, + "deadline": None, + "payoff_status": PayoffStatus.SETUP_ONLY.value, + "player_interest_level": 0.7, + "gm_importance": 0.9, + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow(), + "resolved_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": "Lord Blackwood", + "entity_type": "character", + } + + +@pytest.fixture +def scene_node_data(story_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample scene node data.""" + return { + "id": str(uuid4()), + "story_id": story_data["id"], + "title": "The Investigation Begins", + } + + +@pytest.fixture +def event_node_data() -> Dict[str, Any]: + """Provide sample event node data.""" + return { + "id": str(uuid4()), + "description": "The artifact was stolen", + } + + +# ============================================================================= +# TESTS: neo4j_create_plot_thread +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_plot_thread_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + plot_thread_data: Dict[str, Any], +): + """Test successful plot thread creation.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock story exists check + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], # Story exists verification + [ + { # Return created thread with relationships + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + # Mock write operations + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadCreate( + story_id=UUID(story_data["id"]), + title="The Missing Artifact", + thread_type=PlotThreadType.MAIN, + priority=ThreadPriority.MAIN, + urgency=ThreadUrgency.MEDIUM, + player_interest_level=0.7, + gm_importance=0.9, + ) + + result = neo4j_create_plot_thread(params) + + assert result.story_id == UUID(story_data["id"]) + assert result.title == "The Missing Artifact" + assert result.thread_type == PlotThreadType.MAIN + assert result.priority == ThreadPriority.MAIN + assert result.urgency == ThreadUrgency.MEDIUM + assert result.status == PlotThreadStatus.OPEN + assert result.player_interest_level == 0.7 + assert result.gm_importance == 0.9 + + # Verify story exists check was called + assert mock_neo4j_client.execute_read.call_count >= 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_plot_thread_with_relationships( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + plot_thread_data: Dict[str, Any], + entity_data: Dict[str, Any], + scene_node_data: Dict[str, Any], + event_node_data: Dict[str, Any], +): + """Test creating plot thread with relationships.""" + mock_get_client.return_value = mock_neo4j_client + + scene_id = UUID(scene_node_data["id"]) + entity_id = UUID(entity_data["id"]) + event_id = UUID(event_node_data["id"]) + + # Mock responses + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], # Story exists + [ + { # Return with relationships + "t": plot_thread_data, + "scene_ids": [str(scene_id)], + "entity_ids": [str(entity_id)], + "foreshadowing_event_ids": [str(event_id)], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadCreate( + story_id=UUID(story_data["id"]), + title="The Missing Artifact", + thread_type=PlotThreadType.MAIN, + priority=ThreadPriority.MAIN, + scene_ids=[scene_id], + entity_ids=[entity_id], + foreshadowing_events=[event_id], + ) + + result = neo4j_create_plot_thread(params) + + assert len(result.scene_ids) == 1 + assert result.scene_ids[0] == scene_id + assert len(result.entity_ids) == 1 + assert result.entity_ids[0] == entity_id + assert len(result.foreshadowing_events) == 1 + assert result.foreshadowing_events[0] == event_id + + # Verify relationship creation calls + assert mock_neo4j_client.execute_write.call_count >= 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_plot_thread_with_deadline( + mock_get_client: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + plot_thread_data: Dict[str, Any], +): + """Test creating plot thread with deadline.""" + mock_get_client.return_value = mock_neo4j_client + + deadline_time = datetime.utcnow() + thread_with_deadline = plot_thread_data.copy() + thread_with_deadline["deadline"] = { + "world_time": deadline_time.isoformat(), + "description": "Before the kingdom falls", + } + + mock_neo4j_client.execute_read.side_effect = [ + [{"id": story_data["id"]}], + [ + { + "t": thread_with_deadline, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + deadline = ThreadDeadline( + world_time=deadline_time, + description="Before the kingdom falls", + ) + + params = PlotThreadCreate( + story_id=UUID(story_data["id"]), + title="Save the Kingdom", + thread_type=PlotThreadType.MAIN, + priority=ThreadPriority.MAIN, + urgency=ThreadUrgency.CRITICAL, + deadline=deadline, + ) + + result = neo4j_create_plot_thread(params) + + assert result.deadline is not None + assert result.deadline.description == "Before the kingdom falls" + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_plot_thread_story_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test plot thread creation fails when story doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] # Story not found + + params = PlotThreadCreate( + story_id=uuid4(), + title="Test Thread", + thread_type=PlotThreadType.MAIN, + priority=ThreadPriority.MAIN, + ) + + with pytest.raises(ValueError, match="not found"): + neo4j_create_plot_thread(params) + + +# ============================================================================= +# TESTS: neo4j_get_plot_thread +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_plot_thread_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test successful plot thread retrieval.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.return_value = [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ] + + thread_id = UUID(plot_thread_data["id"]) + result = neo4j_get_plot_thread(thread_id) + + assert result is not None + assert result.id == thread_id + assert result.title == "The Missing Artifact" + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_plot_thread_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test plot thread retrieval when thread doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + result = neo4j_get_plot_thread(uuid4()) + + assert result is None + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_plot_thread_with_relationships( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test getting plot thread with all relationships populated.""" + mock_get_client.return_value = mock_neo4j_client + + scene_id = str(uuid4()) + entity_id = str(uuid4()) + foreshadow_id = str(uuid4()) + reveal_id = str(uuid4()) + + mock_neo4j_client.execute_read.return_value = [ + { + "t": plot_thread_data, + "scene_ids": [scene_id], + "entity_ids": [entity_id], + "foreshadowing_event_ids": [foreshadow_id], + "revelation_event_ids": [reveal_id], + } + ] + + result = neo4j_get_plot_thread(UUID(plot_thread_data["id"])) + + assert result is not None + assert len(result.scene_ids) == 1 + assert str(result.scene_ids[0]) == scene_id + assert len(result.entity_ids) == 1 + assert str(result.entity_ids[0]) == entity_id + assert len(result.foreshadowing_events) == 1 + assert str(result.foreshadowing_events[0]) == foreshadow_id + assert len(result.revelation_events) == 1 + assert str(result.revelation_events[0]) == reveal_id + + +# ============================================================================= +# TESTS: neo4j_update_plot_thread +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_title( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test updating plot thread title.""" + mock_get_client.return_value = mock_neo4j_client + + # First read: get existing, second read: get updated + existing_thread = plot_thread_data.copy() + updated_thread = plot_thread_data.copy() + updated_thread["title"] = "Updated Thread Title" + + mock_neo4j_client.execute_read.side_effect = [ + [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + [ + { + "t": updated_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadUpdate( + title="Updated Thread Title", + ) + + result = neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + assert result.title == "Updated Thread Title" + + # Verify update was called + mock_neo4j_client.execute_write.assert_called_once() + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_status_valid_transition( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test valid status transition.""" + mock_get_client.return_value = mock_neo4j_client + + existing_thread = plot_thread_data.copy() + existing_thread["status"] = PlotThreadStatus.OPEN.value + + updated_thread = plot_thread_data.copy() + updated_thread["status"] = PlotThreadStatus.ADVANCED.value + + mock_neo4j_client.execute_read.side_effect = [ + [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + [ + { + "t": updated_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadUpdate( + status=PlotThreadStatus.ADVANCED, + ) + + result = neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + assert result.status == PlotThreadStatus.ADVANCED + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_status_invalid_transition( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test invalid status transition is rejected.""" + mock_get_client.return_value = mock_neo4j_client + + existing_thread = plot_thread_data.copy() + existing_thread["status"] = PlotThreadStatus.RESOLVED.value + + mock_neo4j_client.execute_read.return_value = [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ] + + params = PlotThreadUpdate( + status=PlotThreadStatus.OPEN, # Can't go back from resolved + ) + + with pytest.raises(ValueError, match="Invalid status transition"): + neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_resolve_sets_timestamp( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test that resolving a thread sets resolved_at timestamp.""" + mock_get_client.return_value = mock_neo4j_client + + existing_thread = plot_thread_data.copy() + existing_thread["status"] = PlotThreadStatus.ADVANCED.value + + updated_thread = plot_thread_data.copy() + updated_thread["status"] = PlotThreadStatus.RESOLVED.value + updated_thread["resolved_at"] = datetime.utcnow() + + mock_neo4j_client.execute_read.side_effect = [ + [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + [ + { + "t": updated_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadUpdate( + status=PlotThreadStatus.RESOLVED, + ) + + result = neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + assert result.status == PlotThreadStatus.RESOLVED + assert result.resolved_at is not None + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_add_scenes( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test adding scene relationships to plot thread.""" + mock_get_client.return_value = mock_neo4j_client + + existing_thread = plot_thread_data.copy() + new_scene_id = uuid4() + + updated_thread = plot_thread_data.copy() + + mock_neo4j_client.execute_read.side_effect = [ + [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + [ + { + "t": updated_thread, + "scene_ids": [str(new_scene_id)], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadUpdate( + add_scene_ids=[new_scene_id], + ) + + result = neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + assert len(result.scene_ids) == 1 + assert result.scene_ids[0] == new_scene_id + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_add_entities( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test adding entity relationships to plot thread.""" + mock_get_client.return_value = mock_neo4j_client + + existing_thread = plot_thread_data.copy() + new_entity_id = uuid4() + + updated_thread = plot_thread_data.copy() + + mock_neo4j_client.execute_read.side_effect = [ + [ + { + "t": existing_thread, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + [ + { + "t": updated_thread, + "scene_ids": [], + "entity_ids": [str(new_entity_id)], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + mock_neo4j_client.execute_write.return_value = Mock() + + params = PlotThreadUpdate( + add_entity_ids=[new_entity_id], + ) + + result = neo4j_update_plot_thread(UUID(plot_thread_data["id"]), params) + + assert len(result.entity_ids) == 1 + assert result.entity_ids[0] == new_entity_id + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_plot_thread_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test updating non-existent plot thread.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + params = PlotThreadUpdate(title="New Title") + + with pytest.raises(ValueError, match="not found"): + neo4j_update_plot_thread(uuid4(), params) + + +# ============================================================================= +# TESTS: neo4j_list_plot_threads +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_all( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test listing all plot threads.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], # Count + [ + { # List results + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter() + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + assert len(result.threads) == 1 + assert result.threads[0].title == "The Missing Artifact" + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_filter_by_story( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], + story_data: Dict[str, Any], +): + """Test filtering plot threads by story.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], + [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + story_id=UUID(story_data["id"]), + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + assert result.threads[0].story_id == UUID(story_data["id"]) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_filter_by_type( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test filtering plot threads by type.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], + [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + thread_type=PlotThreadType.MAIN, + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + assert result.threads[0].thread_type == PlotThreadType.MAIN + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_filter_by_status( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test filtering plot threads by status.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], + [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + status=PlotThreadStatus.OPEN, + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + assert result.threads[0].status == PlotThreadStatus.OPEN + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_filter_by_entity( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], + entity_data: Dict[str, Any], +): + """Test filtering plot threads by involved entity.""" + mock_get_client.return_value = mock_neo4j_client + + entity_id = UUID(entity_data["id"]) + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], + [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [str(entity_id)], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + entity_id=entity_id, + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + assert entity_id in result.threads[0].entity_ids + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_pagination( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test plot thread pagination.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 10}], # Total count + [ + { # Single result (second page) + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + limit=1, + offset=1, + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 10 + assert result.limit == 1 + assert result.offset == 1 + assert len(result.threads) == 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_plot_threads_sorting( + mock_get_client: Mock, + mock_neo4j_client: Mock, + plot_thread_data: Dict[str, Any], +): + """Test plot thread sorting.""" + mock_get_client.return_value = mock_neo4j_client + + mock_neo4j_client.execute_read.side_effect = [ + [{"total": 1}], + [ + { + "t": plot_thread_data, + "scene_ids": [], + "entity_ids": [], + "foreshadowing_event_ids": [], + "revelation_event_ids": [], + } + ], + ] + + params = PlotThreadFilter( + sort_by="urgency", + sort_order="desc", + ) + + result = neo4j_list_plot_threads(params) + + assert result.total == 1 + # Verify query was built correctly by checking it executed + assert mock_neo4j_client.execute_read.call_count == 2 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 new file mode 100644 index 0000000..c6107c9 --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_story_outline_tools.py @@ -0,0 +1,595 @@ +""" +Unit tests for MongoDB story outline operations (DL-6). + +Tests cover: +- mongodb_create_story_outline +- mongodb_get_story_outline +- mongodb_update_story_outline +""" + +from typing import Dict, Any +from unittest.mock import Mock, patch, MagicMock +from uuid import UUID, uuid4 +from datetime import datetime + +import pytest + +from monitor_data.schemas.story_outlines import ( + StoryOutlineCreate, + StoryOutlineUpdate, + StoryBeat, + MysteryStructure, + MysteryClue, +) +from monitor_data.schemas.base import ( + StoryStructureType, + ArcTemplate, + BeatStatus, + ClueVisibility, +) +from monitor_data.tools.mongodb_tools import ( + mongodb_create_story_outline, + mongodb_get_story_outline, + mongodb_update_story_outline, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def story_beat_data() -> Dict[str, Any]: + """Provide sample story beat data.""" + beat_id = uuid4() + return { + "beat_id": str(beat_id), + "title": "Opening Scene", + "description": "Introduce the heroes in the tavern", + "order": 0, + "status": BeatStatus.PENDING.value, + "optional": False, + "related_threads": [], + "required_for_threads": [], + "created_at": datetime.utcnow(), + "started_at": None, + "completed_at": None, + "completed_in_scene_id": None, + } + + +@pytest.fixture +def mystery_clue_data() -> Dict[str, Any]: + """Provide sample mystery clue data.""" + return { + "clue_id": str(uuid4()), + "content": "A bloody dagger was found at the scene", + "discovery_methods": ["search", "investigation"], + "is_discovered": False, + "discovered_in_scene_id": None, + "discovered_at": None, + "points_to": "butler", + "visibility": ClueVisibility.HIDDEN.value, + } + + +@pytest.fixture +def story_outline_data( + story_data: Dict[str, Any], story_beat_data: Dict[str, Any] +) -> Dict[str, Any]: + """Provide sample story outline data.""" + return { + "story_id": story_data["id"], + "theme": "Mystery in the manor", + "premise": "A murder has occurred and players must solve it", + "constraints": ["No time travel", "No resurrections"], + "beats": [story_beat_data], + "structure_type": StoryStructureType.LINEAR.value, + "template": ArcTemplate.MYSTERY.value, + "branching_points": [], + "mystery_structure": None, + "pacing_metrics": { + "current_act": 1, + "tension_level": 0.0, + "scenes_since_major_event": 0, + "scenes_in_current_act": 0, + "estimated_completion": 0.0, + "last_updated": datetime.utcnow(), + }, + "open_threads": [], + "created_at": datetime.utcnow(), + "updated_at": datetime.utcnow(), + } + + +# ============================================================================= +# TESTS: mongodb_create_story_outline +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_story_outline_success( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test successful story outline creation.""" + # Setup Neo4j mock + mock_get_neo4j.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [{"id": story_data["id"]}] + + # Setup MongoDB mock + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + mock_collection.find_one.return_value = None # No existing outline + mock_collection.insert_one.return_value = Mock() + + # Create test beat + beat = StoryBeat( + title="Opening Scene", + description="Introduce the heroes", + order=0, + ) + + params = StoryOutlineCreate( + story_id=UUID(story_data["id"]), + theme="Adventure and mystery", + premise="Heroes embark on a quest", + beats=[beat], + structure_type=StoryStructureType.LINEAR, + template=ArcTemplate.THREE_ACT, + ) + + result = mongodb_create_story_outline(params) + + assert result.story_id == UUID(story_data["id"]) + assert result.theme == "Adventure and mystery" + assert result.premise == "Heroes embark on a quest" + assert len(result.beats) == 1 + assert result.beats[0].title == "Opening Scene" + assert result.structure_type == StoryStructureType.LINEAR + assert result.template == ArcTemplate.THREE_ACT + + # Verify Neo4j was called to verify story exists + mock_neo4j_client.execute_read.assert_called_once() + + # Verify MongoDB insert was called + mock_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_story_outline_story_not_found( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_neo4j_client: Mock, +): + """Test story outline creation fails when story doesn't exist.""" + # Setup Neo4j mock - story not found + mock_get_neo4j.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + params = StoryOutlineCreate( + story_id=uuid4(), + theme="Test theme", + premise="Test premise", + ) + + with pytest.raises(ValueError, match="not found"): + mongodb_create_story_outline(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_story_outline_already_exists( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test story outline creation fails when outline already exists.""" + # Setup Neo4j mock + mock_get_neo4j.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [{"id": story_data["id"]}] + + # Setup MongoDB mock - outline 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 + mock_collection.find_one.return_value = story_outline_data # Existing outline + + params = StoryOutlineCreate( + story_id=UUID(story_data["id"]), + ) + + with pytest.raises(ValueError, match="already exists"): + mongodb_create_story_outline(params) + + +@patch("monitor_data.tools.mongodb_tools.get_neo4j_client") +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_create_story_outline_with_mystery( + mock_get_mongo: Mock, + mock_get_neo4j: Mock, + mock_neo4j_client: Mock, + story_data: Dict[str, Any], +): + """Test creating story outline with mystery structure.""" + # Setup mocks + mock_get_neo4j.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [{"id": story_data["id"]}] + + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + mock_collection.find_one.return_value = None + mock_collection.insert_one.return_value = Mock() + + # Create mystery structure + mystery = MysteryStructure( + truth="The butler did it with a candlestick", + question="Who killed Lord Blackwood?", + core_clues=[ + MysteryClue( + content="A bloody dagger", + discovery_methods=["search"], + ) + ], + ) + + params = StoryOutlineCreate( + story_id=UUID(story_data["id"]), + theme="Mystery", + premise="Murder mystery", + mystery_structure=mystery, + ) + + result = mongodb_create_story_outline(params) + + assert result.mystery_structure is not None + assert result.mystery_structure.truth == "The butler did it with a candlestick" + assert result.mystery_structure.question == "Who killed Lord Blackwood?" + assert len(result.mystery_structure.core_clues) == 1 + + +# ============================================================================= +# TESTS: mongodb_get_story_outline +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_story_outline_success( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test successful story outline retrieval.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + mock_collection.find_one.return_value = story_outline_data + + result = mongodb_get_story_outline(UUID(story_data["id"])) + + assert result is not None + assert result.story_id == UUID(story_data["id"]) + assert result.theme == "Mystery in the manor" + assert len(result.beats) == 1 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_get_story_outline_not_found(mock_get_mongo: Mock): + """Test story outline retrieval when outline doesn't exist.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + mock_collection.find_one.return_value = None + + result = mongodb_get_story_outline(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: mongodb_update_story_outline +# ============================================================================= + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_theme_and_premise( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test updating story outline theme and premise.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + # First call returns existing doc, second call returns updated doc + updated_data = story_outline_data.copy() + updated_data["theme"] = "Updated theme" + updated_data["premise"] = "Updated premise" + mock_collection.find_one.side_effect = [story_outline_data, updated_data] + mock_collection.update_one.return_value = Mock() + + params = StoryOutlineUpdate( + theme="Updated theme", + premise="Updated premise", + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert result.theme == "Updated theme" + assert result.premise == "Updated premise" + + # Verify update was called + mock_collection.update_one.assert_called_once() + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_add_beats( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test adding beats to story outline.""" + 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 updated data with new beat + new_beat = StoryBeat( + title="Second Beat", + description="New story beat", + order=1, + ) + updated_data = story_outline_data.copy() + updated_data["beats"].append(new_beat.model_dump(mode="json")) + + mock_collection.find_one.side_effect = [story_outline_data, updated_data] + mock_collection.update_one.return_value = Mock() + + params = StoryOutlineUpdate( + add_beats=[new_beat], + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert len(result.beats) == 2 + assert result.beats[1].title == "Second Beat" + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_remove_beats( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test removing beats from story outline.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + # Setup data with multiple beats + beat1_id = uuid4() + beat2_id = uuid4() + multi_beat_data = story_outline_data.copy() + multi_beat_data["beats"] = [ + { + "beat_id": str(beat1_id), + "title": "Beat 1", + "description": "First beat", + "order": 0, + "status": BeatStatus.PENDING.value, + "optional": False, + "related_threads": [], + "required_for_threads": [], + "created_at": datetime.utcnow(), + }, + { + "beat_id": str(beat2_id), + "title": "Beat 2", + "description": "Second beat", + "order": 1, + "status": BeatStatus.PENDING.value, + "optional": False, + "related_threads": [], + "required_for_threads": [], + "created_at": datetime.utcnow(), + }, + ] + + updated_data = multi_beat_data.copy() + updated_data["beats"] = [multi_beat_data["beats"][0]] # Only first beat remains + + mock_collection.find_one.side_effect = [multi_beat_data, updated_data] + mock_collection.update_one.return_value = Mock() + + params = StoryOutlineUpdate( + remove_beat_ids=[beat2_id], + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert len(result.beats) == 1 + assert result.beats[0].beat_id == beat1_id + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_reorder_beats( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test reordering beats in story outline.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + beat1_id = uuid4() + beat2_id = uuid4() + multi_beat_data = story_outline_data.copy() + multi_beat_data["beats"] = [ + { + "beat_id": str(beat1_id), + "title": "Beat 1", + "description": "First beat", + "order": 0, + "status": BeatStatus.PENDING.value, + "optional": False, + "related_threads": [], + "required_for_threads": [], + "created_at": datetime.utcnow(), + }, + { + "beat_id": str(beat2_id), + "title": "Beat 2", + "description": "Second beat", + "order": 1, + "status": BeatStatus.PENDING.value, + "optional": False, + "related_threads": [], + "required_for_threads": [], + "created_at": datetime.utcnow(), + }, + ] + + # Reordered: beat2 comes before beat1 + updated_data = multi_beat_data.copy() + updated_data["beats"] = [multi_beat_data["beats"][1], multi_beat_data["beats"][0]] + updated_data["beats"][0]["order"] = 0 + updated_data["beats"][1]["order"] = 1 + + mock_collection.find_one.side_effect = [multi_beat_data, updated_data] + mock_collection.update_one.return_value = Mock() + + params = StoryOutlineUpdate( + reorder_beats=[beat2_id, beat1_id], + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert result.beats[0].beat_id == beat2_id + assert result.beats[0].order == 0 + assert result.beats[1].beat_id == beat1_id + assert result.beats[1].order == 1 + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_update_beats( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], +): + """Test updating existing beats in story outline.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + beat_id = UUID(story_outline_data["beats"][0]["beat_id"]) + + updated_data = story_outline_data.copy() + updated_data["beats"][0]["status"] = BeatStatus.COMPLETED.value + updated_data["beats"][0]["completed_at"] = datetime.utcnow() + + mock_collection.find_one.side_effect = [story_outline_data, updated_data] + mock_collection.update_one.return_value = Mock() + + # Update the beat status + updated_beat = StoryBeat( + beat_id=beat_id, + title="Opening Scene", + description="Introduce the heroes in the tavern", + order=0, + status=BeatStatus.COMPLETED, + completed_at=datetime.utcnow(), + ) + + params = StoryOutlineUpdate( + update_beats=[updated_beat], + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert result.beats[0].status == BeatStatus.COMPLETED + assert result.beats[0].completed_at is not None + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_not_found(mock_get_mongo: Mock): + """Test updating non-existent story outline.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + mock_collection.find_one.return_value = None + + params = StoryOutlineUpdate(theme="New theme") + + with pytest.raises(ValueError, match="not found"): + mongodb_update_story_outline(uuid4(), params) + + +@patch("monitor_data.tools.mongodb_tools.get_mongodb_client") +def test_update_story_outline_mark_clue_discovered( + mock_get_mongo: Mock, + story_data: Dict[str, Any], + story_outline_data: Dict[str, Any], + mystery_clue_data: Dict[str, Any], +): + """Test marking a clue as discovered in mystery structure.""" + mock_mongo_client = MagicMock() + mock_collection = MagicMock() + mock_get_mongo.return_value = mock_mongo_client + mock_mongo_client.get_collection.return_value = mock_collection + + # Add mystery structure to outline + outline_with_mystery = story_outline_data.copy() + outline_with_mystery["mystery_structure"] = { + "truth": "The butler did it", + "question": "Who killed Lord Blackwood?", + "core_clues": [mystery_clue_data], + "bonus_clues": [], + "red_herrings": [], + "suspects": [], + "current_player_theories": [], + } + + # Updated with discovered clue + updated_data = outline_with_mystery.copy() + updated_data["mystery_structure"]["core_clues"][0]["is_discovered"] = True + updated_data["mystery_structure"]["core_clues"][0][ + "visibility" + ] = ClueVisibility.DISCOVERED.value + + mock_collection.find_one.side_effect = [outline_with_mystery, updated_data] + mock_collection.update_one.return_value = Mock() + + clue_id = UUID(mystery_clue_data["clue_id"]) + params = StoryOutlineUpdate( + mark_clue_discovered=clue_id, + ) + + result = mongodb_update_story_outline(UUID(story_data["id"]), params) + + assert result.mystery_structure is not None + assert result.mystery_structure.core_clues[0].is_discovered is True + assert ( + result.mystery_structure.core_clues[0].visibility == ClueVisibility.DISCOVERED + )