Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ venv/
htmlcov/
.coverage
.pytest_cache/

# MCP plugin workspace
.serena/

# Package lock files
uv.lock
2 changes: 1 addition & 1 deletion packages/data-layer/src/monitor_data/schemas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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"
)
Expand Down Expand Up @@ -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))


# =============================================================================
Expand Down
40 changes: 30 additions & 10 deletions packages/data-layer/src/monitor_data/tools/mongodb_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Comment on lines +1071 to +1080
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic for checking if all beat IDs are included in the reorder operation is missing test coverage. Consider adding test cases for:

  1. Attempting to reorder with fewer beat IDs than currently exist (should raise ValueError)
  2. Attempting to reorder with an invalid beat ID that doesn't exist in current beats (should raise ValueError)

These tests would ensure the new validation behaves correctly when users provide incomplete or incorrect reorder lists.

Copilot uses AI. Check for mistakes.
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]
Expand All @@ -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"
)
Comment on lines +1103 to +1106
Copy link

Copilot AI Jan 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation logic that checks if mystery structure is None when marking a clue as discovered is missing test coverage. Consider adding a test case that attempts to mark a clue as discovered when the story outline has mystery_structure field present in the document but set to None. This would verify that the ValueError is correctly raised in this edge case.

Copilot uses AI. Check for mistakes.
clue_id_str = str(params.mark_clue_discovered)
now = datetime.now(timezone.utc)

Expand Down
2 changes: 1 addition & 1 deletion packages/data-layer/src/monitor_data/tools/neo4j_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions packages/data-layer/tests/test_tools/test_story_outline_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)