diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index 5dcc057..6ea5157 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -55,11 +55,11 @@ "neo4j_create_fact": ["CanonKeeper"], "neo4j_get_fact": ["*"], "neo4j_list_facts": ["*"], - "neo4j_retcon_fact": ["CanonKeeper"], + "neo4j_update_fact": ["CanonKeeper"], + "neo4j_delete_fact": ["CanonKeeper"], "neo4j_create_event": ["CanonKeeper"], "neo4j_get_event": ["*"], "neo4j_list_events": ["*"], - "neo4j_link_causal": ["CanonKeeper"], # ========================================================================= # NEO4J OPERATIONS - Stories # ========================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/facts.py b/packages/data-layer/src/monitor_data/schemas/facts.py new file mode 100644 index 0000000..a870787 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/facts.py @@ -0,0 +1,225 @@ +""" +Pydantic schemas for Fact and Event operations. + +LAYER: 1 (data-layer) +IMPORTS FROM: External libraries (pydantic, uuid, datetime) and base schemas +CALLED BY: neo4j_tools.py + +These schemas define the data contracts for Fact and Event CRUD operations. +Facts represent canonical truth about the world; Events are temporal facts with timestamps. +""" + +from datetime import datetime +from enum import Enum +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import Authority, CanonLevel + + +# ============================================================================= +# ENUMS +# ============================================================================= + + +class FactType(str, Enum): + """Type classification for facts.""" + + STATE = "state" # "Door is broken", "NPC is hostile" + RELATIONSHIP = "relationship" # "PC is allied with NPC" + ATTRIBUTE = "attribute" # "PC has 5 HP" + OCCURRENCE = "occurrence" # "PC took 5 damage" (distinct from Event entity) + + +# ============================================================================= +# FACT SCHEMAS +# ============================================================================= + + +class FactCreate(BaseModel): + """Request to create a Fact.""" + + universe_id: UUID + statement: str = Field(min_length=1, max_length=2000, description="The fact statement") + fact_type: FactType = Field(default=FactType.STATE) + + # Optional temporal properties + time_ref: Optional[datetime] = Field(None, description="When it became true") + duration: Optional[int] = Field(None, description="How long it was true (seconds)") + + # Entity references + entity_ids: Optional[List[UUID]] = Field( + default=None, description="Entity IDs this fact involves" + ) + + # Provenance references + source_ids: Optional[List[UUID]] = Field( + default=None, description="Source IDs supporting this fact" + ) + snippet_ids: Optional[List[str]] = Field( + default=None, + description="Snippet IDs supporting this fact (stored for reference, not as Neo4j edges)" + ) + scene_ids: Optional[List[UUID]] = Field( + default=None, description="Scene IDs supporting this fact" + ) + + # Canonization metadata + canon_level: CanonLevel = Field(default=CanonLevel.PROPOSED) + confidence: float = Field(ge=0.0, le=1.0, default=1.0) + authority: Authority = Field(default=Authority.SYSTEM) + + # Optional retcon tracking + replaces: Optional[UUID] = Field(None, description="Fact ID this retcons") + + # Custom properties + properties: Optional[dict] = Field( + default=None, description="Additional custom properties" + ) + + +class FactUpdate(BaseModel): + """Request to update a Fact. + + Only mutable fields can be updated: statement, canon_level, confidence, properties. + Structural fields like universe_id and fact_type require creating a new fact. + """ + + statement: Optional[str] = Field(None, min_length=1, max_length=2000) + canon_level: Optional[CanonLevel] = None + confidence: Optional[float] = Field(None, ge=0.0, le=1.0) + properties: Optional[dict] = None + + +class FactResponse(BaseModel): + """Response with Fact data including relationships.""" + + id: UUID + universe_id: UUID + statement: str + fact_type: FactType + time_ref: Optional[datetime] + duration: Optional[int] + canon_level: CanonLevel + confidence: float + authority: Authority + created_at: datetime + replaces: Optional[UUID] + properties: Optional[dict] + + # Relationship data (populated by get operations) + entity_ids: List[UUID] = Field(default_factory=list) + source_ids: List[UUID] = Field(default_factory=list) + snippet_ids: List[str] = Field(default_factory=list) + scene_ids: List[UUID] = Field(default_factory=list) + + model_config = {"from_attributes": True} + + +class FactFilter(BaseModel): + """Filter parameters for listing facts.""" + + universe_id: Optional[UUID] = None + entity_id: Optional[UUID] = Field(None, description="Facts involving this entity") + fact_type: Optional[FactType] = None + canon_level: Optional[CanonLevel] = None + limit: int = Field(default=30, ge=1, le=100) + offset: int = Field(default=0, ge=0) + + +# ============================================================================= +# EVENT SCHEMAS +# ============================================================================= + + +class EventCreate(BaseModel): + """Request to create an Event.""" + + universe_id: UUID + title: str = Field(min_length=1, max_length=200) + description: Optional[str] = Field(None, max_length=2000) + + # Temporal properties (required for events) + start_time: datetime = Field(description="When the event started") + end_time: Optional[datetime] = Field(None, description="When the event ended") + + # Optional scene reference + scene_id: Optional[UUID] = None + + # Severity/importance + severity: int = Field(default=5, ge=0, le=10, description="Event severity 0-10") + + # Entity references + entity_ids: Optional[List[UUID]] = Field( + default=None, description="Entity IDs involved in this event" + ) + + # Provenance references + source_ids: Optional[List[UUID]] = Field( + default=None, description="Source IDs supporting this event" + ) + + # Timeline ordering (for establishing BEFORE and AFTER edges) + timeline_after: Optional[List[UUID]] = Field( + default=None, description="Event IDs this event comes after" + ) + timeline_before: Optional[List[UUID]] = Field( + default=None, description="Event IDs this event comes before" + ) + + # Causal relationships + causes: Optional[List[UUID]] = Field( + default=None, description="Event IDs this event causes" + ) + + # Canonization metadata + canon_level: CanonLevel = Field(default=CanonLevel.PROPOSED) + confidence: float = Field(ge=0.0, le=1.0, default=1.0) + authority: Authority = Field(default=Authority.SYSTEM) + + # Custom properties + properties: Optional[dict] = Field( + default=None, description="Additional custom properties" + ) + + +class EventResponse(BaseModel): + """Response with Event data including relationships.""" + + id: UUID + universe_id: UUID + scene_id: Optional[UUID] + title: str + description: Optional[str] + start_time: datetime + end_time: Optional[datetime] + severity: int + canon_level: CanonLevel + confidence: float + authority: Authority + created_at: datetime + properties: Optional[dict] + + # Relationship data (populated by get operations) + entity_ids: List[UUID] = Field(default_factory=list) + source_ids: List[UUID] = Field(default_factory=list) + timeline_after: List[UUID] = Field(default_factory=list) + timeline_before: List[UUID] = Field(default_factory=list) + causes: List[UUID] = Field(default_factory=list) + + model_config = {"from_attributes": True} + + +class EventFilter(BaseModel): + """Filter parameters for listing events.""" + + universe_id: Optional[UUID] = None + scene_id: Optional[UUID] = None + entity_id: Optional[UUID] = Field(None, description="Events involving this entity") + canon_level: Optional[CanonLevel] = None + start_after: Optional[datetime] = Field(None, description="Events starting after this time") + start_before: Optional[datetime] = Field(None, description="Events starting before this time") + limit: int = Field(default=30, ge=1, le=100) + offset: int = Field(default=0, ge=0) 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 8cccee2..4532e3a 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -14,6 +14,7 @@ from uuid import UUID, uuid4 from monitor_data.db.neo4j import get_neo4j_client +from monitor_data.schemas.base import CanonLevel from monitor_data.schemas.universe import ( UniverseCreate, UniverseUpdate, @@ -30,6 +31,15 @@ EntityListResponse, StateTagsUpdate, ) +from monitor_data.schemas.facts import ( + FactCreate, + FactUpdate, + FactResponse, + FactFilter, + EventCreate, + EventResponse, + EventFilter, +) # ============================================================================= @@ -1091,3 +1101,872 @@ def neo4j_set_state_tags( created_at=e["created_at"], updated_at=e.get("updated_at"), ) + + +# ============================================================================= +# FACT OPERATIONS +# ============================================================================= + + +def neo4j_create_fact(params: FactCreate) -> FactResponse: + """ + Create a new Fact node with provenance and entity relationships. + + Authority: CanonKeeper only + Use Case: DL-3 + + Args: + params: Fact creation parameters + + Returns: + FactResponse with created fact data + + Raises: + ValueError: If universe_id doesn't exist or entity references are invalid + """ + client = get_neo4j_client() + + # Verify universe exists + verify_query = """ + MATCH (u:Universe {id: $universe_id}) + RETURN u.id as id + """ + result = client.execute_read(verify_query, {"universe_id": str(params.universe_id)}) + if not result: + raise ValueError(f"Universe {params.universe_id} not found") + + # Verify entity references if provided + if params.entity_ids: + entity_check_query = """ + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + RETURN e.id as id + """ + for entity_id in params.entity_ids: + result = client.execute_read(entity_check_query, {"entity_id": str(entity_id)}) + if not result: + raise ValueError(f"Entity {entity_id} not found") + + # Verify source references if provided + if params.source_ids: + source_check_query = """ + MATCH (s:Source {id: $source_id}) + RETURN s.id as id + """ + for source_id in params.source_ids: + result = client.execute_read(source_check_query, {"source_id": str(source_id)}) + if not result: + raise ValueError(f"Source {source_id} not found") + + # Verify scene references if provided + if params.scene_ids: + scene_check_query = """ + MATCH (sc:Scene {id: $scene_id}) + RETURN sc.id as id + """ + for scene_id in params.scene_ids: + result = client.execute_read(scene_check_query, {"scene_id": str(scene_id)}) + if not result: + raise ValueError(f"Scene {scene_id} not found") + + # Verify replaces reference if provided + if params.replaces: + replaces_check_query = """ + MATCH (old:Fact {id: $replaces_id}) + RETURN old.id as id + """ + result = client.execute_read(replaces_check_query, {"replaces_id": str(params.replaces)}) + if not result: + raise ValueError(f"Fact to replace {params.replaces} not found") + + # Create fact node + fact_id = uuid4() + created_at = datetime.now(timezone.utc) + + create_query = """ + MATCH (u:Universe {id: $universe_id}) + CREATE (f:Fact { + id: $id, + universe_id: $universe_id, + statement: $statement, + fact_type: $fact_type, + time_ref: CASE WHEN $time_ref IS NOT NULL THEN datetime($time_ref) ELSE null END, + duration: $duration, + canon_level: $canon_level, + confidence: $confidence, + authority: $authority, + created_at: datetime($created_at), + replaces: $replaces, + properties: $properties + }) + CREATE (u)-[:HAS_FACT]->(f) + RETURN f + """ + + client.execute_write( + create_query, + { + "id": str(fact_id), + "universe_id": str(params.universe_id), + "statement": params.statement, + "fact_type": params.fact_type.value, + "time_ref": params.time_ref.isoformat() if params.time_ref else None, + "duration": params.duration, + "canon_level": params.canon_level.value, + "confidence": params.confidence, + "authority": params.authority.value, + "created_at": created_at.isoformat(), + "replaces": str(params.replaces) if params.replaces else None, + "properties": params.properties, + }, + ) + + # Create INVOLVES edges to entities + if params.entity_ids: + entity_edge_query = """ + MATCH (f:Fact {id: $fact_id}) + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + CREATE (f)-[:INVOLVES]->(e) + """ + for entity_id in params.entity_ids: + client.execute_write( + entity_edge_query, + {"fact_id": str(fact_id), "entity_id": str(entity_id)}, + ) + + # Create SUPPORTED_BY edges to sources + if params.source_ids: + source_edge_query = """ + MATCH (f:Fact {id: $fact_id}) + MATCH (s:Source {id: $source_id}) + CREATE (f)-[:SUPPORTED_BY]->(s) + """ + for source_id in params.source_ids: + client.execute_write( + source_edge_query, + {"fact_id": str(fact_id), "source_id": str(source_id)}, + ) + + # Create SUPPORTED_BY edges to scenes + if params.scene_ids: + scene_edge_query = """ + MATCH (f:Fact {id: $fact_id}) + MATCH (sc:Scene {id: $scene_id}) + CREATE (f)-[:SUPPORTED_BY]->(sc) + """ + for scene_id in params.scene_ids: + client.execute_write( + scene_edge_query, + {"fact_id": str(fact_id), "scene_id": str(scene_id)}, + ) + + # Create REPLACES edge if this retcons another fact + if params.replaces: + replaces_edge_query = """ + MATCH (f:Fact {id: $fact_id}) + MATCH (old:Fact {id: $replaces_id}) + CREATE (f)-[:REPLACES]->(old) + SET old.canon_level = $retconned_level + """ + client.execute_write( + replaces_edge_query, + { + "fact_id": str(fact_id), + "replaces_id": str(params.replaces), + "retconned_level": CanonLevel.RETCONNED.value, + }, + ) + + # Retrieve with relationships + result = neo4j_get_fact(fact_id) + if result is None: + raise ValueError(f"Failed to retrieve created fact {fact_id}") + return result + + +def neo4j_get_fact(fact_id: UUID) -> Optional[FactResponse]: + """ + Get a Fact by ID with all relationships and provenance chain. + + Authority: Any agent (read-only) + Use Case: DL-3 + + Args: + fact_id: UUID of the fact + + Returns: + FactResponse if found, None otherwise + """ + client = get_neo4j_client() + + query = """ + MATCH (f:Fact {id: $id}) + OPTIONAL MATCH (f)-[:INVOLVES]->(e) + WHERE e:EntityArchetype OR e:EntityInstance + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(sc:Scene) + RETURN f, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT sc.id) as scene_ids + """ + result = client.execute_read(query, {"id": str(fact_id)}) + + if not result: + return None + + record = result[0] + f = record["f"] + + return FactResponse( + id=UUID(f["id"]), + universe_id=UUID(f["universe_id"]), + statement=f["statement"], + fact_type=f["fact_type"], + time_ref=f.get("time_ref"), + duration=f.get("duration"), + canon_level=f["canon_level"], + confidence=f["confidence"], + authority=f["authority"], + created_at=f["created_at"], + replaces=UUID(f["replaces"]) if f.get("replaces") else None, + properties=f.get("properties"), + entity_ids=[UUID(eid) for eid in record["entity_ids"] if eid], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + snippet_ids=[], # Snippets not stored in Neo4j + scene_ids=[UUID(scid) for scid in record["scene_ids"] if scid], + ) + + +def neo4j_list_facts(filters: Optional[FactFilter] = None) -> List[FactResponse]: + """ + List facts with optional filtering and pagination. + + Authority: Any agent (read-only) + Use Case: DL-3 + + Args: + filters: Optional filter parameters + + Returns: + List of FactResponse objects + """ + client = get_neo4j_client() + + if filters is None: + filters = FactFilter() + + # Build WHERE clause + where_clauses = [] + params: Dict[str, Any] = { + "limit": filters.limit, + "offset": filters.offset, + } + + if filters.universe_id: + where_clauses.append("f.universe_id = $universe_id") + params["universe_id"] = str(filters.universe_id) + + if filters.fact_type: + where_clauses.append("f.fact_type = $fact_type") + params["fact_type"] = filters.fact_type.value + + if filters.canon_level: + where_clauses.append("f.canon_level = $canon_level") + params["canon_level"] = filters.canon_level.value + + # Handle entity filter separately + if filters.entity_id: + # When filtering by entity, we need to match the INVOLVES relationship + # and combine it with other filters using AND + where_clauses.insert(0, "e.id = $entity_id") + where_clause = "WHERE " + " AND ".join(where_clauses) + + query = f""" + MATCH (f:Fact)-[:INVOLVES]->(e) + {where_clause} + OPTIONAL MATCH (f)-[:INVOLVES]->(e2) + WHERE e2:EntityArchetype OR e2:EntityInstance + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(sc:Scene) + RETURN f, + collect(DISTINCT e2.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT sc.id) as scene_ids + ORDER BY f.created_at DESC + SKIP $offset + LIMIT $limit + """ + params["entity_id"] = str(filters.entity_id) + else: + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + query = f""" + MATCH (f:Fact) + {where_clause} + OPTIONAL MATCH (f)-[:INVOLVES]->(e) + WHERE e:EntityArchetype OR e:EntityInstance + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (f)-[:SUPPORTED_BY]->(sc:Scene) + RETURN f, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT sc.id) as scene_ids + ORDER BY f.created_at DESC + SKIP $offset + LIMIT $limit + """ + + results = client.execute_read(query, params) + + facts = [] + for record in results: + f = record["f"] + facts.append( + FactResponse( + id=UUID(f["id"]), + universe_id=UUID(f["universe_id"]), + statement=f["statement"], + fact_type=f["fact_type"], + time_ref=f.get("time_ref"), + duration=f.get("duration"), + canon_level=f["canon_level"], + confidence=f["confidence"], + authority=f["authority"], + created_at=f["created_at"], + replaces=UUID(f["replaces"]) if f.get("replaces") else None, + properties=f.get("properties"), + entity_ids=[UUID(eid) for eid in record["entity_ids"] if eid], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + snippet_ids=[], + scene_ids=[UUID(scid) for scid in record["scene_ids"] if scid], + ) + ) + + return facts + + +def neo4j_update_fact(fact_id: UUID, params: FactUpdate) -> FactResponse: + """ + Update a Fact's mutable fields. + + Authority: CanonKeeper only + Use Case: DL-3 + + Args: + fact_id: UUID of the fact to update + params: Update parameters + + Returns: + FactResponse with updated fact data + + Raises: + ValueError: If fact doesn't exist + """ + client = get_neo4j_client() + + # Verify fact exists + verify_query = """ + MATCH (f:Fact {id: $id}) + RETURN f + """ + result = client.execute_read(verify_query, {"id": str(fact_id)}) + if not result: + raise ValueError(f"Fact {fact_id} not found") + + # Build SET clause for only provided fields + set_clauses = [] + update_params: Dict[str, Any] = {"id": str(fact_id)} + + if params.statement is not None: + set_clauses.append("f.statement = $statement") + update_params["statement"] = params.statement + + if params.canon_level is not None: + set_clauses.append("f.canon_level = $canon_level") + update_params["canon_level"] = params.canon_level.value + + if params.confidence is not None: + set_clauses.append("f.confidence = $confidence") + update_params["confidence"] = params.confidence + + if params.properties is not None: + set_clauses.append("f.properties = $properties") + update_params["properties"] = params.properties + + if not set_clauses: + # No updates, just return current state + result = neo4j_get_fact(fact_id) + if result is None: + raise ValueError(f"Fact {fact_id} not found after verification") + return result + + set_clause = ", ".join(set_clauses) + update_query = f""" + MATCH (f:Fact {{id: $id}}) + SET {set_clause} + RETURN f + """ + + client.execute_write(update_query, update_params) + + # Retrieve updated fact with relationships + result = neo4j_get_fact(fact_id) + if result is None: + raise ValueError(f"Fact {fact_id} not found after update") + return result + + +def neo4j_delete_fact(fact_id: UUID, force: bool = False) -> Dict[str, Any]: + """ + Delete a Fact node. + + Authority: CanonKeeper only + Use Case: DL-3 + + Args: + fact_id: UUID of the fact to delete + force: If True, allow deletion of canon facts. If False, prevent deletion of canon facts. + + Returns: + Dict with deletion status + + Raises: + ValueError: If fact doesn't exist or is canon (when force=False) + """ + client = get_neo4j_client() + + # Verify fact exists + verify_query = """ + MATCH (f:Fact {id: $id}) + RETURN f.canon_level as canon_level + """ + result = client.execute_read(verify_query, {"id": str(fact_id)}) + if not result: + raise ValueError(f"Fact {fact_id} not found") + + canon_level = result[0]["canon_level"] + + # Prevent deletion of canon facts unless force=True + if canon_level == CanonLevel.CANON.value and not force: + raise ValueError( + f"Cannot delete canon fact {fact_id} without force=True. " + "Canon facts must be explicitly retconned before deletion." + ) + + # Delete fact and all relationships + delete_query = """ + MATCH (f:Fact {id: $id}) + DETACH DELETE f + """ + client.execute_write(delete_query, {"id": str(fact_id)}) + + return { + "fact_id": str(fact_id), + "deleted": True, + "canon_level": canon_level, + "forced": force, + } + + +# ============================================================================= +# EVENT OPERATIONS +# ============================================================================= + + +def neo4j_create_event(params: EventCreate) -> EventResponse: + """ + Create a new Event node with temporal properties and timeline edges. + + Authority: CanonKeeper only + Use Case: DL-3 + + Args: + params: Event creation parameters + + Returns: + EventResponse with created event data + + Raises: + ValueError: If universe_id doesn't exist or entity/scene references are invalid + """ + client = get_neo4j_client() + + # Verify universe exists + verify_query = """ + MATCH (u:Universe {id: $universe_id}) + RETURN u.id as id + """ + result = client.execute_read(verify_query, {"universe_id": str(params.universe_id)}) + if not result: + raise ValueError(f"Universe {params.universe_id} not found") + + # Verify scene if provided + if params.scene_id: + scene_check_query = """ + MATCH (sc:Scene {id: $scene_id}) + RETURN sc.id as id + """ + result = client.execute_read(scene_check_query, {"scene_id": str(params.scene_id)}) + if not result: + raise ValueError(f"Scene {params.scene_id} not found") + + # Verify entity references if provided + if params.entity_ids: + entity_check_query = """ + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + RETURN e.id as id + """ + for entity_id in params.entity_ids: + result = client.execute_read(entity_check_query, {"entity_id": str(entity_id)}) + if not result: + raise ValueError(f"Entity {entity_id} not found") + + # Verify source references if provided + if params.source_ids: + source_check_query = """ + MATCH (s:Source {id: $source_id}) + RETURN s.id as id + """ + for source_id in params.source_ids: + result = client.execute_read(source_check_query, {"source_id": str(source_id)}) + if not result: + raise ValueError(f"Source {source_id} not found") + + # Verify timeline_after event references if provided + if params.timeline_after: + event_check_query = """ + MATCH (ev:Event {id: $event_id}) + RETURN ev.id as id + """ + for after_id in params.timeline_after: + result = client.execute_read(event_check_query, {"event_id": str(after_id)}) + if not result: + raise ValueError(f"Timeline after event {after_id} not found") + + # Verify timeline_before event references if provided + if params.timeline_before: + event_check_query = """ + MATCH (ev:Event {id: $event_id}) + RETURN ev.id as id + """ + for before_id in params.timeline_before: + result = client.execute_read(event_check_query, {"event_id": str(before_id)}) + if not result: + raise ValueError(f"Timeline before event {before_id} not found") + + # Verify causes event references if provided + if params.causes: + event_check_query = """ + MATCH (ev:Event {id: $event_id}) + RETURN ev.id as id + """ + for caused_id in params.causes: + result = client.execute_read(event_check_query, {"event_id": str(caused_id)}) + if not result: + raise ValueError(f"Caused event {caused_id} not found") + + # Create event node + event_id = uuid4() + created_at = datetime.now(timezone.utc) + + create_query = """ + MATCH (u:Universe {id: $universe_id}) + CREATE (ev:Event { + id: $id, + universe_id: $universe_id, + scene_id: $scene_id, + title: $title, + description: $description, + start_time: datetime($start_time), + end_time: CASE WHEN $end_time IS NOT NULL THEN datetime($end_time) ELSE null END, + severity: $severity, + canon_level: $canon_level, + confidence: $confidence, + authority: $authority, + created_at: datetime($created_at), + properties: $properties + }) + CREATE (u)-[:HAS_EVENT]->(ev) + RETURN ev + """ + + client.execute_write( + create_query, + { + "id": str(event_id), + "universe_id": str(params.universe_id), + "scene_id": str(params.scene_id) if params.scene_id else None, + "title": params.title, + "description": params.description, + "start_time": params.start_time.isoformat(), + "end_time": params.end_time.isoformat() if params.end_time else None, + "severity": params.severity, + "canon_level": params.canon_level.value, + "confidence": params.confidence, + "authority": params.authority.value, + "created_at": created_at.isoformat(), + "properties": params.properties, + }, + ) + + # Create INVOLVES edges to entities + if params.entity_ids: + entity_edge_query = """ + MATCH (ev:Event {id: $event_id}) + MATCH (e {id: $entity_id}) + WHERE e:EntityArchetype OR e:EntityInstance + CREATE (ev)-[:INVOLVES]->(e) + """ + for entity_id in params.entity_ids: + client.execute_write( + entity_edge_query, + {"event_id": str(event_id), "entity_id": str(entity_id)}, + ) + + # Create SUPPORTED_BY edges to sources + if params.source_ids: + source_edge_query = """ + MATCH (ev:Event {id: $event_id}) + MATCH (s:Source {id: $source_id}) + CREATE (ev)-[:SUPPORTED_BY]->(s) + """ + for source_id in params.source_ids: + client.execute_write( + source_edge_query, + {"event_id": str(event_id), "source_id": str(source_id)}, + ) + + # Create timeline edges (AFTER) + if params.timeline_after: + for after_id in params.timeline_after: + after_edge_query = """ + MATCH (ev1:Event {id: $event_id}) + MATCH (ev2:Event {id: $after_id}) + CREATE (ev1)-[:AFTER]->(ev2) + """ + client.execute_write( + after_edge_query, + {"event_id": str(event_id), "after_id": str(after_id)}, + ) + + # Create timeline edges (BEFORE) + if params.timeline_before: + for before_id in params.timeline_before: + before_edge_query = """ + MATCH (ev1:Event {id: $event_id}) + MATCH (ev2:Event {id: $before_id}) + CREATE (ev1)-[:BEFORE]->(ev2) + """ + client.execute_write( + before_edge_query, + {"event_id": str(event_id), "before_id": str(before_id)}, + ) + + # Create CAUSES edges + if params.causes: + for caused_id in params.causes: + causes_edge_query = """ + MATCH (ev1:Event {id: $event_id}) + MATCH (ev2:Event {id: $caused_id}) + CREATE (ev1)-[:CAUSES]->(ev2) + """ + client.execute_write( + causes_edge_query, + {"event_id": str(event_id), "caused_id": str(caused_id)}, + ) + + # Retrieve with relationships + result = neo4j_get_event(event_id) + if result is None: + raise ValueError(f"Failed to retrieve created event {event_id}") + return result + + +def neo4j_get_event(event_id: UUID) -> Optional[EventResponse]: + """ + Get an Event by ID with all relationships. + + Authority: Any agent (read-only) + Use Case: DL-3 + + Args: + event_id: UUID of the event + + Returns: + EventResponse if found, None otherwise + """ + client = get_neo4j_client() + + query = """ + MATCH (ev:Event {id: $id}) + OPTIONAL MATCH (ev)-[:INVOLVES]->(e) + WHERE e:EntityArchetype OR e:EntityInstance + OPTIONAL MATCH (ev)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (ev)-[:AFTER]->(after:Event) + OPTIONAL MATCH (ev)-[:BEFORE]->(before:Event) + OPTIONAL MATCH (ev)-[:CAUSES]->(caused:Event) + RETURN ev, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT after.id) as timeline_after, + collect(DISTINCT before.id) as timeline_before, + collect(DISTINCT caused.id) as causes + """ + result = client.execute_read(query, {"id": str(event_id)}) + + if not result: + return None + + record = result[0] + ev = record["ev"] + + return EventResponse( + id=UUID(ev["id"]), + universe_id=UUID(ev["universe_id"]), + scene_id=UUID(ev["scene_id"]) if ev.get("scene_id") else None, + title=ev["title"], + description=ev.get("description"), + start_time=ev["start_time"], + end_time=ev.get("end_time"), + severity=ev["severity"], + canon_level=ev["canon_level"], + confidence=ev["confidence"], + authority=ev["authority"], + created_at=ev["created_at"], + properties=ev.get("properties"), + entity_ids=[UUID(eid) for eid in record["entity_ids"] if eid], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + timeline_after=[UUID(aid) for aid in record["timeline_after"] if aid], + timeline_before=[UUID(bid) for bid in record["timeline_before"] if bid], + causes=[UUID(cid) for cid in record["causes"] if cid], + ) + + +def neo4j_list_events(filters: Optional[EventFilter] = None) -> List[EventResponse]: + """ + List events with optional filtering and pagination. + + Authority: Any agent (read-only) + Use Case: DL-3 + + Args: + filters: Optional filter parameters + + Returns: + List of EventResponse objects + """ + client = get_neo4j_client() + + if filters is None: + filters = EventFilter() + + # Build WHERE clause + where_clauses = [] + params: Dict[str, Any] = { + "limit": filters.limit, + "offset": filters.offset, + } + + if filters.universe_id: + where_clauses.append("ev.universe_id = $universe_id") + params["universe_id"] = str(filters.universe_id) + + if filters.scene_id: + where_clauses.append("ev.scene_id = $scene_id") + params["scene_id"] = str(filters.scene_id) + + if filters.canon_level: + where_clauses.append("ev.canon_level = $canon_level") + params["canon_level"] = filters.canon_level.value + + if filters.start_after: + where_clauses.append("ev.start_time >= datetime($start_after)") + params["start_after"] = filters.start_after.isoformat() + + if filters.start_before: + where_clauses.append("ev.start_time <= datetime($start_before)") + params["start_before"] = filters.start_before.isoformat() + + # Handle entity filter separately + if filters.entity_id: + # When filtering by entity, we need to match the INVOLVES relationship + # and combine it with other filters using AND + where_clauses.insert(0, "e.id = $entity_id") + where_clause = "WHERE " + " AND ".join(where_clauses) + + query = f""" + MATCH (ev:Event)-[:INVOLVES]->(e) + {where_clause} + OPTIONAL MATCH (ev)-[:INVOLVES]->(e2) + WHERE e2:EntityArchetype OR e2:EntityInstance + OPTIONAL MATCH (ev)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (ev)-[:AFTER]->(after:Event) + OPTIONAL MATCH (ev)-[:BEFORE]->(before:Event) + OPTIONAL MATCH (ev)-[:CAUSES]->(caused:Event) + RETURN ev, + collect(DISTINCT e2.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT after.id) as timeline_after, + collect(DISTINCT before.id) as timeline_before, + collect(DISTINCT caused.id) as causes + ORDER BY ev.start_time DESC + SKIP $offset + LIMIT $limit + """ + params["entity_id"] = str(filters.entity_id) + else: + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + query = f""" + MATCH (ev:Event) + {where_clause} + OPTIONAL MATCH (ev)-[:INVOLVES]->(e) + WHERE e:EntityArchetype OR e:EntityInstance + OPTIONAL MATCH (ev)-[:SUPPORTED_BY]->(s:Source) + OPTIONAL MATCH (ev)-[:AFTER]->(after:Event) + OPTIONAL MATCH (ev)-[:BEFORE]->(before:Event) + OPTIONAL MATCH (ev)-[:CAUSES]->(caused:Event) + RETURN ev, + collect(DISTINCT e.id) as entity_ids, + collect(DISTINCT s.id) as source_ids, + collect(DISTINCT after.id) as timeline_after, + collect(DISTINCT before.id) as timeline_before, + collect(DISTINCT caused.id) as causes + ORDER BY ev.start_time DESC + SKIP $offset + LIMIT $limit + """ + + results = client.execute_read(query, params) + + events = [] + for record in results: + ev = record["ev"] + events.append( + EventResponse( + id=UUID(ev["id"]), + universe_id=UUID(ev["universe_id"]), + scene_id=UUID(ev["scene_id"]) if ev.get("scene_id") else None, + title=ev["title"], + description=ev.get("description"), + start_time=ev["start_time"], + end_time=ev.get("end_time"), + severity=ev["severity"], + canon_level=ev["canon_level"], + confidence=ev["confidence"], + authority=ev["authority"], + created_at=ev["created_at"], + properties=ev.get("properties"), + entity_ids=[UUID(eid) for eid in record["entity_ids"] if eid], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + timeline_after=[UUID(aid) for aid in record["timeline_after"] if aid], + timeline_before=[UUID(bid) for bid in record["timeline_before"] if bid], + causes=[UUID(cid) for cid in record["causes"] if cid], + ) + ) + + return events + diff --git a/packages/data-layer/tests/test_tools/test_fact_tools.py b/packages/data-layer/tests/test_tools/test_fact_tools.py new file mode 100644 index 0000000..a96842f --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_fact_tools.py @@ -0,0 +1,911 @@ +""" +Unit tests for Neo4j fact and event operations. + +Tests cover: +- neo4j_create_fact +- neo4j_get_fact +- neo4j_list_facts +- neo4j_update_fact +- neo4j_delete_fact +- neo4j_create_event +- neo4j_get_event +- neo4j_list_events +""" + +from datetime import datetime, timezone +from typing import Dict, Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 + +import pytest + +from monitor_data.schemas.facts import ( + FactCreate, + FactUpdate, + FactFilter, + FactType, + EventCreate, + EventFilter, +) +from monitor_data.schemas.base import CanonLevel, Authority +from monitor_data.tools.neo4j_tools import ( + neo4j_create_fact, + neo4j_get_fact, + neo4j_list_facts, + neo4j_update_fact, + neo4j_delete_fact, + neo4j_create_event, + neo4j_get_event, + neo4j_list_events, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def fact_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample fact data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "statement": "The door is broken", + "fact_type": FactType.STATE.value, + "time_ref": None, + "duration": None, + "canon_level": CanonLevel.PROPOSED.value, + "confidence": 1.0, + "authority": Authority.SYSTEM.value, + "created_at": "2024-01-01T00:00:00", + "replaces": None, + "properties": None, + } + + +@pytest.fixture +def event_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample event data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "scene_id": None, + "title": "Orc attacks PC", + "description": "A fierce orc swings its axe at the PC", + "start_time": "2024-01-01T12:00:00", + "end_time": "2024-01-01T12:01:00", + "severity": 7, + "canon_level": CanonLevel.CANON.value, + "confidence": 1.0, + "authority": Authority.GM.value, + "created_at": "2024-01-01T00:00:00", + "properties": None, + } + + +@pytest.fixture +def entity_data() -> Dict[str, Any]: + """Provide sample entity data.""" + return { + "id": str(uuid4()), + "universe_id": str(uuid4()), + "name": "Test Entity", + "entity_type": "character", + } + + +# ============================================================================= +# TESTS: neo4j_create_fact +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_create_fact_success( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + fact_data: Dict[str, Any], +): + """Test successful fact creation.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists check + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock fact creation + mock_neo4j_client.execute_write.return_value = [{"f": fact_data}] + + # Mock get_fact to return created fact + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement=fact_data["statement"], + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.PROPOSED, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=None, + properties=None, + ) + mock_get_fact.return_value = mock_fact_response + + params = FactCreate( + universe_id=UUID(universe_data["id"]), + statement="The door is broken", + fact_type=FactType.STATE, + ) + + result = neo4j_create_fact(params) + + assert result.statement == "The door is broken" + assert result.universe_id == UUID(universe_data["id"]) + assert result.fact_type == FactType.STATE + assert result.canon_level == CanonLevel.PROPOSED + assert mock_neo4j_client.execute_read.call_count >= 1 + assert mock_neo4j_client.execute_write.call_count >= 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_fact_invalid_universe( + mock_get_client: Mock, mock_neo4j_client: Mock +): + """Test fact creation with invalid universe_id.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe doesn't exist + mock_neo4j_client.execute_read.return_value = [] + + params = FactCreate( + universe_id=uuid4(), + statement="Test fact", + ) + + with pytest.raises(ValueError, match="Universe .* not found"): + neo4j_create_fact(params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_create_fact_with_provenance( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + fact_data: Dict[str, Any], +): + """Test fact creation with provenance edges (source_ids).""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock fact creation and edge creation + mock_neo4j_client.execute_write.return_value = [{"f": fact_data}] + + source_id = uuid4() + + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement="Test fact with source", + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.PROPOSED, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=None, + properties=None, + source_ids=[source_id], + ) + mock_get_fact.return_value = mock_fact_response + + params = FactCreate( + universe_id=UUID(universe_data["id"]), + statement="Test fact with source", + source_ids=[source_id], + ) + + result = neo4j_create_fact(params) + + assert result.statement == "Test fact with source" + assert source_id in result.source_ids + # Verify edge creation was called + assert mock_neo4j_client.execute_write.call_count >= 2 # create + edge + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_create_fact_with_entities( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + entity_data: Dict[str, Any], + fact_data: Dict[str, Any], +): + """Test fact creation with entity references (INVOLVES edges).""" + mock_get_client.return_value = mock_neo4j_client + + entity_id = UUID(entity_data["id"]) + + # Mock universe exists, then entity exists + mock_neo4j_client.execute_read.side_effect = [ + [{"id": universe_data["id"]}], # universe check + [{"id": entity_data["id"]}], # entity check + ] + + # Mock fact creation and edge creation + mock_neo4j_client.execute_write.return_value = [{"f": fact_data}] + + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement="Test fact with entity", + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.PROPOSED, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=None, + properties=None, + entity_ids=[entity_id], + ) + mock_get_fact.return_value = mock_fact_response + + params = FactCreate( + universe_id=UUID(universe_data["id"]), + statement="Test fact with entity", + entity_ids=[entity_id], + ) + + result = neo4j_create_fact(params) + + assert result.statement == "Test fact with entity" + assert entity_id in result.entity_ids + # Verify INVOLVES edge was created + assert mock_neo4j_client.execute_write.call_count >= 2 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_create_fact_with_retcon( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + fact_data: Dict[str, Any], +): + """Test fact creation that replaces (retcons) another fact.""" + mock_get_client.return_value = mock_neo4j_client + + old_fact_id = uuid4() + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock fact creation and REPLACES edge + mock_neo4j_client.execute_write.return_value = [{"f": fact_data}] + + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement=fact_data["statement"], + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.PROPOSED, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=old_fact_id, + properties=None, + ) + mock_get_fact.return_value = mock_fact_response + + params = FactCreate( + universe_id=UUID(universe_data["id"]), + statement="New fact replacing old one", + replaces=old_fact_id, + ) + + result = neo4j_create_fact(params) + + assert result.replaces == old_fact_id + # Verify REPLACES edge was created + assert mock_neo4j_client.execute_write.call_count >= 2 + + +# ============================================================================= +# TESTS: neo4j_get_fact +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_fact_with_relationships( + mock_get_client: Mock, + mock_neo4j_client: Mock, + fact_data: Dict[str, Any], +): + """Test getting fact with all relationships and provenance chain.""" + mock_get_client.return_value = mock_neo4j_client + + entity_id = str(uuid4()) + source_id = str(uuid4()) + scene_id = str(uuid4()) + + # Mock query result with relationships + mock_neo4j_client.execute_read.return_value = [ + { + "f": fact_data, + "entity_ids": [entity_id], + "source_ids": [source_id], + "scene_ids": [scene_id], + } + ] + + result = neo4j_get_fact(UUID(fact_data["id"])) + + assert result is not None + assert result.id == UUID(fact_data["id"]) + assert result.statement == fact_data["statement"] + assert len(result.entity_ids) == 1 + assert len(result.source_ids) == 1 + assert len(result.scene_ids) == 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_fact_not_found(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test getting non-existent fact returns None.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + result = neo4j_get_fact(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: neo4j_list_facts +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_facts_by_entity( + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + fact_data: Dict[str, Any], +): + """Test listing facts filtered by entity_id.""" + mock_get_client.return_value = mock_neo4j_client + + entity_id = uuid4() + + # Mock query result + mock_neo4j_client.execute_read.return_value = [ + { + "f": fact_data, + "entity_ids": [str(entity_id)], + "source_ids": [], + "scene_ids": [], + } + ] + + filters = FactFilter(entity_id=entity_id) + results = neo4j_list_facts(filters) + + assert len(results) == 1 + assert results[0].id == UUID(fact_data["id"]) + assert entity_id in results[0].entity_ids + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_facts_by_canon_level( + mock_get_client: Mock, + mock_neo4j_client: Mock, + fact_data: Dict[str, Any], +): + """Test listing facts filtered by canon_level.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock query result + mock_neo4j_client.execute_read.return_value = [ + { + "f": fact_data, + "entity_ids": [], + "source_ids": [], + "scene_ids": [], + } + ] + + filters = FactFilter(canon_level=CanonLevel.PROPOSED) + results = neo4j_list_facts(filters) + + assert len(results) == 1 + assert results[0].canon_level == CanonLevel.PROPOSED + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_facts_by_fact_type( + mock_get_client: Mock, + mock_neo4j_client: Mock, + fact_data: Dict[str, Any], +): + """Test listing facts filtered by fact_type.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock query result + mock_neo4j_client.execute_read.return_value = [ + { + "f": fact_data, + "entity_ids": [], + "source_ids": [], + "scene_ids": [], + } + ] + + filters = FactFilter(fact_type=FactType.STATE) + results = neo4j_list_facts(filters) + + assert len(results) == 1 + assert results[0].fact_type == FactType.STATE + + +# ============================================================================= +# TESTS: neo4j_update_fact +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_update_fact_canon_level( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + fact_data: Dict[str, Any], +): + """Test updating fact canon_level (proposed → canon transition).""" + mock_get_client.return_value = mock_neo4j_client + + # Mock fact exists + mock_neo4j_client.execute_read.return_value = [{"f": fact_data}] + + # Mock update + updated_fact = fact_data.copy() + updated_fact["canon_level"] = CanonLevel.CANON.value + mock_neo4j_client.execute_write.return_value = [{"f": updated_fact}] + + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement=fact_data["statement"], + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.CANON, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=None, + properties=None, + ) + mock_get_fact.return_value = mock_fact_response + + params = FactUpdate(canon_level=CanonLevel.CANON) + result = neo4j_update_fact(UUID(fact_data["id"]), params) + + assert result.canon_level == CanonLevel.CANON + mock_neo4j_client.execute_write.assert_called_once() + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_fact_not_found(mock_get_client: Mock, mock_neo4j_client: Mock): + """Test updating non-existent fact raises error.""" + mock_get_client.return_value = mock_neo4j_client + mock_neo4j_client.execute_read.return_value = [] + + params = FactUpdate(statement="Updated statement") + + with pytest.raises(ValueError, match="Fact .* not found"): + neo4j_update_fact(uuid4(), params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_fact") +def test_update_fact_statement( + mock_get_fact: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + fact_data: Dict[str, Any], +): + """Test updating fact statement.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock fact exists + mock_neo4j_client.execute_read.return_value = [{"f": fact_data}] + + # Mock update + updated_fact = fact_data.copy() + updated_fact["statement"] = "Updated statement" + mock_neo4j_client.execute_write.return_value = [{"f": updated_fact}] + + from monitor_data.schemas.facts import FactResponse + + mock_fact_response = FactResponse( + id=UUID(fact_data["id"]), + universe_id=UUID(fact_data["universe_id"]), + statement="Updated statement", + fact_type=FactType.STATE, + time_ref=None, + duration=None, + canon_level=CanonLevel.PROPOSED, + confidence=fact_data["confidence"], + authority=Authority.SYSTEM, + created_at=datetime.fromisoformat(fact_data["created_at"]), + replaces=None, + properties=None, + ) + mock_get_fact.return_value = mock_fact_response + + params = FactUpdate(statement="Updated statement") + result = neo4j_update_fact(UUID(fact_data["id"]), params) + + assert result.statement == "Updated statement" + + +# ============================================================================= +# TESTS: neo4j_delete_fact +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_confirmed_fact_without_force( + mock_get_client: Mock, mock_neo4j_client: Mock, fact_data: Dict[str, Any] +): + """Test deleting canon fact without force raises error.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock fact exists and is canon + canon_fact = fact_data.copy() + canon_fact["canon_level"] = CanonLevel.CANON.value + mock_neo4j_client.execute_read.return_value = [ + {"canon_level": CanonLevel.CANON.value} + ] + + with pytest.raises(ValueError, match="Cannot delete canon fact"): + neo4j_delete_fact(UUID(fact_data["id"]), force=False) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_confirmed_fact_with_force( + mock_get_client: Mock, mock_neo4j_client: Mock, fact_data: Dict[str, Any] +): + """Test deleting canon fact with force succeeds.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock fact exists and is canon + mock_neo4j_client.execute_read.return_value = [ + {"canon_level": CanonLevel.CANON.value} + ] + mock_neo4j_client.execute_write.return_value = [] + + result = neo4j_delete_fact(UUID(fact_data["id"]), force=True) + + assert result["deleted"] is True + assert result["forced"] is True + assert result["canon_level"] == CanonLevel.CANON.value + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_proposed_fact( + mock_get_client: Mock, mock_neo4j_client: Mock, fact_data: Dict[str, Any] +): + """Test deleting proposed fact succeeds without force.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock fact exists and is proposed + mock_neo4j_client.execute_read.return_value = [ + {"canon_level": CanonLevel.PROPOSED.value} + ] + mock_neo4j_client.execute_write.return_value = [] + + result = neo4j_delete_fact(UUID(fact_data["id"]), force=False) + + assert result["deleted"] is True + assert result["forced"] is False + + +# ============================================================================= +# TESTS: neo4j_create_event +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_event") +def test_create_event_success( + mock_get_event: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + event_data: Dict[str, Any], +): + """Test successful event creation.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock event creation + mock_neo4j_client.execute_write.return_value = [{"ev": event_data}] + + from monitor_data.schemas.facts import EventResponse + + mock_event_response = EventResponse( + id=UUID(event_data["id"]), + universe_id=UUID(event_data["universe_id"]), + scene_id=None, + title=event_data["title"], + description=event_data["description"], + start_time=datetime.fromisoformat(event_data["start_time"]), + end_time=datetime.fromisoformat(event_data["end_time"]), + severity=event_data["severity"], + canon_level=CanonLevel.CANON, + confidence=event_data["confidence"], + authority=Authority.GM, + created_at=datetime.fromisoformat(event_data["created_at"]), + properties=None, + ) + mock_get_event.return_value = mock_event_response + + params = EventCreate( + universe_id=UUID(universe_data["id"]), + title="Orc attacks PC", + description="A fierce orc swings its axe at the PC", + start_time=datetime.fromisoformat(event_data["start_time"]), + end_time=datetime.fromisoformat(event_data["end_time"]), + severity=7, + canon_level=CanonLevel.CANON, + authority=Authority.GM, + ) + + result = neo4j_create_event(params) + + assert result.title == "Orc attacks PC" + assert result.severity == 7 + assert mock_neo4j_client.execute_write.call_count >= 1 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_event") +def test_create_event_with_timeline( + mock_get_event: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + event_data: Dict[str, Any], +): + """Test event creation with timeline ordering (AFTER, BEFORE edges).""" + mock_get_client.return_value = mock_neo4j_client + + after_event_id = uuid4() + before_event_id = uuid4() + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock event creation and timeline edge creation + mock_neo4j_client.execute_write.return_value = [{"ev": event_data}] + + from monitor_data.schemas.facts import EventResponse + + mock_event_response = EventResponse( + id=UUID(event_data["id"]), + universe_id=UUID(event_data["universe_id"]), + scene_id=None, + title=event_data["title"], + description=event_data["description"], + start_time=datetime.fromisoformat(event_data["start_time"]), + end_time=datetime.fromisoformat(event_data["end_time"]), + severity=event_data["severity"], + canon_level=CanonLevel.CANON, + confidence=event_data["confidence"], + authority=Authority.GM, + created_at=datetime.fromisoformat(event_data["created_at"]), + properties=None, + timeline_after=[after_event_id], + timeline_before=[before_event_id], + ) + mock_get_event.return_value = mock_event_response + + params = EventCreate( + universe_id=UUID(universe_data["id"]), + title="Event with timeline", + start_time=datetime.now(timezone.utc), + timeline_after=[after_event_id], + timeline_before=[before_event_id], + ) + + result = neo4j_create_event(params) + + assert after_event_id in result.timeline_after + assert before_event_id in result.timeline_before + # Verify timeline edges were created (1 create + 2 edges) + assert mock_neo4j_client.execute_write.call_count >= 3 + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_event") +def test_create_event_with_causal( + mock_get_event: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + event_data: Dict[str, Any], +): + """Test event creation with causal relationships (CAUSES edges).""" + mock_get_client.return_value = mock_neo4j_client + + caused_event_id = uuid4() + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock event creation and CAUSES edge creation + mock_neo4j_client.execute_write.return_value = [{"ev": event_data}] + + from monitor_data.schemas.facts import EventResponse + + mock_event_response = EventResponse( + id=UUID(event_data["id"]), + universe_id=UUID(event_data["universe_id"]), + scene_id=None, + title=event_data["title"], + description=event_data["description"], + start_time=datetime.fromisoformat(event_data["start_time"]), + end_time=datetime.fromisoformat(event_data["end_time"]), + severity=event_data["severity"], + canon_level=CanonLevel.CANON, + confidence=event_data["confidence"], + authority=Authority.GM, + created_at=datetime.fromisoformat(event_data["created_at"]), + properties=None, + causes=[caused_event_id], + ) + mock_get_event.return_value = mock_event_response + + params = EventCreate( + universe_id=UUID(universe_data["id"]), + title="Causal event", + start_time=datetime.now(timezone.utc), + causes=[caused_event_id], + ) + + result = neo4j_create_event(params) + + assert caused_event_id in result.causes + # Verify CAUSES edge was created + assert mock_neo4j_client.execute_write.call_count >= 2 + + +# ============================================================================= +# TESTS: neo4j_get_event +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_event_with_relationships( + mock_get_client: Mock, + mock_neo4j_client: Mock, + event_data: Dict[str, Any], +): + """Test getting event with all relationships.""" + mock_get_client.return_value = mock_neo4j_client + + entity_id = str(uuid4()) + source_id = str(uuid4()) + + # Mock query result with relationships + mock_neo4j_client.execute_read.return_value = [ + { + "ev": event_data, + "entity_ids": [entity_id], + "source_ids": [source_id], + "timeline_after": [], + "timeline_before": [], + "causes": [], + } + ] + + result = neo4j_get_event(UUID(event_data["id"])) + + assert result is not None + assert result.id == UUID(event_data["id"]) + assert result.title == event_data["title"] + assert len(result.entity_ids) == 1 + + +# ============================================================================= +# TESTS: neo4j_list_events +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_events_by_scene( + mock_get_client: Mock, + mock_neo4j_client: Mock, + event_data: Dict[str, Any], +): + """Test listing events filtered by scene_id.""" + mock_get_client.return_value = mock_neo4j_client + + scene_id = uuid4() + event_with_scene = event_data.copy() + event_with_scene["scene_id"] = str(scene_id) + + # Mock query result + mock_neo4j_client.execute_read.return_value = [ + { + "ev": event_with_scene, + "entity_ids": [], + "source_ids": [], + "timeline_after": [], + "timeline_before": [], + "causes": [], + } + ] + + filters = EventFilter(scene_id=scene_id) + results = neo4j_list_events(filters) + + assert len(results) == 1 + assert results[0].scene_id == scene_id + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_events_by_time_range( + mock_get_client: Mock, + mock_neo4j_client: Mock, + event_data: Dict[str, Any], +): + """Test listing events filtered by time range.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock query result + mock_neo4j_client.execute_read.return_value = [ + { + "ev": event_data, + "entity_ids": [], + "source_ids": [], + "timeline_after": [], + "timeline_before": [], + "causes": [], + } + ] + + start_after = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + start_before = datetime(2024, 1, 2, 0, 0, 0, tzinfo=timezone.utc) + + filters = EventFilter(start_after=start_after, start_before=start_before) + results = neo4j_list_events(filters) + + assert len(results) == 1