From 4f6a1f399eac3d01d2801517d7620fb5adb7eae1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 02:04:02 +0000 Subject: [PATCH 1/3] Initial plan From cc7f890bcfc1e49a910937386669b8c85f444b31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 02:12:37 +0000 Subject: [PATCH 2/3] Implement DL-13: Manage Axioms - Complete CRUD operations with tests Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .gitignore | 1 + .../src/monitor_data/middleware/auth.py | 2 + .../src/monitor_data/schemas/axioms.py | 93 +++ .../src/monitor_data/schemas/base.py | 12 + .../src/monitor_data/tools/neo4j_tools.py | 355 +++++++++ .../tests/test_tools/test_axiom_tools.py | 712 ++++++++++++++++++ 6 files changed, 1175 insertions(+) create mode 100644 packages/data-layer/src/monitor_data/schemas/axioms.py create mode 100644 packages/data-layer/tests/test_tools/test_axiom_tools.py diff --git a/.gitignore b/.gitignore index b59652d..26e9e10 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ venv/ htmlcov/ .coverage .pytest_cache/ +coverage.json diff --git a/packages/data-layer/src/monitor_data/middleware/auth.py b/packages/data-layer/src/monitor_data/middleware/auth.py index 4335742..6b6d5ec 100644 --- a/packages/data-layer/src/monitor_data/middleware/auth.py +++ b/packages/data-layer/src/monitor_data/middleware/auth.py @@ -80,6 +80,8 @@ "neo4j_create_axiom": ["CanonKeeper"], "neo4j_get_axiom": ["*"], "neo4j_list_axioms": ["*"], + "neo4j_update_axiom": ["CanonKeeper"], + "neo4j_delete_axiom": ["CanonKeeper"], # ========================================================================= # NEO4J OPERATIONS - Plot Threads # ========================================================================= diff --git a/packages/data-layer/src/monitor_data/schemas/axioms.py b/packages/data-layer/src/monitor_data/schemas/axioms.py new file mode 100644 index 0000000..9f8de65 --- /dev/null +++ b/packages/data-layer/src/monitor_data/schemas/axioms.py @@ -0,0 +1,93 @@ +""" +Pydantic schemas for Axiom 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 Axiom CRUD operations. +Axioms represent foundational world rules and constraints tied to universes. +""" + +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +from pydantic import BaseModel, Field + +from monitor_data.schemas.base import AxiomAuthority, AxiomDomain, CanonLevel + + +# ============================================================================= +# AXIOM SCHEMAS +# ============================================================================= + + +class AxiomCreate(BaseModel): + """Request to create an Axiom.""" + + universe_id: UUID + statement: str = Field( + min_length=1, max_length=2000, description="The axiom statement" + ) + domain: AxiomDomain = Field( + default=AxiomDomain.PHYSICS, + description="Domain of the axiom (physics, magic, society, metaphysics)", + ) + + # Provenance references + source_ids: Optional[List[UUID]] = Field( + default=None, description="Source IDs supporting this axiom" + ) + snippet_ids: Optional[List[str]] = Field( + default=None, + description="Snippet IDs supporting this axiom (stored for reference)", + ) + + # Canonization metadata + canon_level: CanonLevel = Field(default=CanonLevel.CANON) + confidence: float = Field(ge=0.0, le=1.0, default=1.0) + authority: AxiomAuthority = Field(default=AxiomAuthority.SYSTEM) + + +class AxiomUpdate(BaseModel): + """Request to update an Axiom. + + Only mutable fields can be updated: statement, canon_level, confidence. + Structural fields like universe_id and domain require creating a new axiom. + """ + + 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) + + +class AxiomResponse(BaseModel): + """Response with Axiom data including provenance.""" + + id: UUID + universe_id: UUID + statement: str + domain: AxiomDomain + canon_level: CanonLevel + confidence: float + authority: AxiomAuthority + created_at: datetime + + # Provenance data (populated by get operations) + source_ids: List[UUID] = Field(default_factory=list) + snippet_ids: List[str] = Field(default_factory=list) + + model_config = {"from_attributes": True} + + +class AxiomFilter(BaseModel): + """Filter parameters for listing axioms.""" + + universe_id: Optional[UUID] = None + domain: Optional[AxiomDomain] = None + canon_level: Optional[CanonLevel] = None + confidence_min: Optional[float] = Field(None, ge=0.0, le=1.0) + confidence_max: Optional[float] = Field(None, ge=0.0, le=1.0) + 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/schemas/base.py b/packages/data-layer/src/monitor_data/schemas/base.py index 6b29198..d3a39a4 100644 --- a/packages/data-layer/src/monitor_data/schemas/base.py +++ b/packages/data-layer/src/monitor_data/schemas/base.py @@ -62,6 +62,18 @@ class AxiomAuthority(str, Enum): SYSTEM = "system" +class AxiomDomain(str, Enum): + """Domain classification for Axiom nodes. + + Categorizes the type of world rule or constraint. + """ + + PHYSICS = "physics" + MAGIC = "magic" + SOCIETY = "society" + METAPHYSICS = "metaphysics" + + class EntityType(str, Enum): """Entity classification.""" 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..5d49b7e 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,12 @@ StoryFilter, StoryListResponse, ) +from monitor_data.schemas.axioms import ( + AxiomCreate, + AxiomUpdate, + AxiomResponse, + AxiomFilter, +) # ============================================================================= @@ -2345,3 +2351,352 @@ def neo4j_list_stories(params: StoryFilter) -> StoryListResponse: return StoryListResponse( stories=stories, total=total, limit=params.limit, offset=params.offset ) + + +# ============================================================================= +# AXIOM OPERATIONS +# ============================================================================= + + +def neo4j_create_axiom(params: AxiomCreate) -> AxiomResponse: + """ + Create a new Axiom node with provenance. + + Authority: CanonKeeper only + Use Case: DL-13 + + Args: + params: Axiom creation parameters + + Returns: + AxiomResponse with created axiom data + + Raises: + ValueError: If universe_id doesn't exist or source 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 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") + + # Create axiom node + axiom_id = uuid4() + created_at = datetime.now(timezone.utc) + + create_query = """ + MATCH (u:Universe {id: $universe_id}) + CREATE (a:Axiom { + id: $id, + universe_id: $universe_id, + statement: $statement, + domain: $domain, + canon_level: $canon_level, + confidence: $confidence, + authority: $authority, + created_at: datetime($created_at) + }) + CREATE (u)-[:HAS_AXIOM]->(a) + RETURN a + """ + + client.execute_write( + create_query, + { + "id": str(axiom_id), + "universe_id": str(params.universe_id), + "statement": params.statement, + "domain": params.domain.value, + "canon_level": params.canon_level.value, + "confidence": params.confidence, + "authority": params.authority.value, + "created_at": created_at.isoformat(), + }, + ) + + # Create SUPPORTED_BY edges to sources + if params.source_ids: + source_edge_query = """ + MATCH (a:Axiom {id: $axiom_id}) + MATCH (s:Source {id: $source_id}) + CREATE (a)-[:SUPPORTED_BY]->(s) + """ + for source_id in params.source_ids: + client.execute_write( + source_edge_query, + {"axiom_id": str(axiom_id), "source_id": str(source_id)}, + ) + + # Retrieve with relationships + axiom = neo4j_get_axiom(axiom_id) + if axiom is None: + raise ValueError(f"Failed to retrieve created axiom {axiom_id}") + return axiom + + +def neo4j_get_axiom(axiom_id: UUID) -> Optional[AxiomResponse]: + """ + Get an Axiom by ID with provenance chain. + + Authority: Any agent (read-only) + Use Case: DL-13 + + Args: + axiom_id: UUID of the axiom + + Returns: + AxiomResponse if found, None otherwise + """ + client = get_neo4j_client() + + query = """ + MATCH (a:Axiom {id: $id}) + OPTIONAL MATCH (a)-[:SUPPORTED_BY]->(s:Source) + RETURN a, + collect(DISTINCT s.id) as source_ids + """ + result = client.execute_read(query, {"id": str(axiom_id)}) + + if not result: + return None + + record = result[0] + a = record["a"] + + return AxiomResponse( + id=UUID(a["id"]), + universe_id=UUID(a["universe_id"]), + statement=a["statement"], + domain=a["domain"], + canon_level=a["canon_level"], + confidence=a["confidence"], + authority=a["authority"], + created_at=a["created_at"], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + snippet_ids=[], # Snippets not stored in Neo4j + ) + + +def neo4j_list_axioms(filters: Optional[AxiomFilter] = None) -> List[AxiomResponse]: + """ + List axioms with optional filters. + + Authority: Any agent (read-only) + Use Case: DL-13 + + Args: + filters: Optional filter parameters (universe_id, domain, confidence range) + + Returns: + List of AxiomResponse objects + """ + client = get_neo4j_client() + + if filters is None: + filters = AxiomFilter() + + # Build WHERE clause based on filters + where_clauses = [] + params = {} + + if filters.universe_id: + where_clauses.append("a.universe_id = $universe_id") + params["universe_id"] = str(filters.universe_id) + + if filters.domain: + where_clauses.append("a.domain = $domain") + params["domain"] = filters.domain.value + + if filters.canon_level: + where_clauses.append("a.canon_level = $canon_level") + params["canon_level"] = filters.canon_level.value + + if filters.confidence_min is not None: + where_clauses.append("a.confidence >= $confidence_min") + params["confidence_min"] = filters.confidence_min + + if filters.confidence_max is not None: + where_clauses.append("a.confidence <= $confidence_max") + params["confidence_max"] = filters.confidence_max + + where_clause = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" + + query = f""" + MATCH (a:Axiom) + {where_clause} + OPTIONAL MATCH (a)-[:SUPPORTED_BY]->(s:Source) + RETURN a, + collect(DISTINCT s.id) as source_ids + ORDER BY a.created_at DESC + SKIP $offset + LIMIT $limit + """ + + params["offset"] = filters.offset + params["limit"] = filters.limit + + results = client.execute_read(query, params) + + axioms = [] + for record in results: + a = record["a"] + axioms.append( + AxiomResponse( + id=UUID(a["id"]), + universe_id=UUID(a["universe_id"]), + statement=a["statement"], + domain=a["domain"], + canon_level=a["canon_level"], + confidence=a["confidence"], + authority=a["authority"], + created_at=a["created_at"], + source_ids=[UUID(sid) for sid in record["source_ids"] if sid], + snippet_ids=[], # Snippets not stored in Neo4j + ) + ) + + return axioms + + +def neo4j_update_axiom(axiom_id: UUID, params: AxiomUpdate) -> AxiomResponse: + """ + Update an existing Axiom. + + Authority: CanonKeeper only + Use Case: DL-13 + + Args: + axiom_id: UUID of the axiom to update + params: Update parameters (statement, canon_level, confidence) + + Returns: + Updated AxiomResponse + + Raises: + ValueError: If axiom doesn't exist + """ + client = get_neo4j_client() + + # Verify axiom exists + verify_query = """ + MATCH (a:Axiom {id: $axiom_id}) + RETURN a.id as id + """ + result = client.execute_read(verify_query, {"axiom_id": str(axiom_id)}) + if not result: + raise ValueError(f"Axiom {axiom_id} not found") + + # Build SET clause for only provided fields + set_clauses = [] + update_params = {"axiom_id": str(axiom_id)} + + if params.statement is not None: + set_clauses.append("a.statement = $statement") + update_params["statement"] = params.statement + + if params.canon_level is not None: + set_clauses.append("a.canon_level = $canon_level") + update_params["canon_level"] = params.canon_level.value + + if params.confidence is not None: + set_clauses.append("a.confidence = $confidence") + update_params["confidence"] = params.confidence + + if not set_clauses: + # No updates provided, just return current axiom + return neo4j_get_axiom(axiom_id) + + set_clause = "SET " + ", ".join(set_clauses) + + update_query = f""" + MATCH (a:Axiom {{id: $axiom_id}}) + {set_clause} + RETURN a + """ + + client.execute_write(update_query, update_params) + + # Retrieve updated axiom + updated_axiom = neo4j_get_axiom(axiom_id) + if updated_axiom is None: + raise ValueError(f"Failed to retrieve updated axiom {axiom_id}") + return updated_axiom + + +def neo4j_delete_axiom(axiom_id: UUID, force: bool = False) -> Dict[str, Any]: + """ + Delete (soft-delete) an Axiom by marking it as retconned. + + Authority: CanonKeeper only + Use Case: DL-13 + + Args: + axiom_id: UUID of the axiom to delete + force: If True, perform hard delete; if False (default), soft-delete via retconned + + Returns: + Dict with deleted axiom_id and whether it was soft or hard deleted + + Raises: + ValueError: If axiom doesn't exist + """ + client = get_neo4j_client() + + # Verify axiom exists + verify_query = """ + MATCH (a:Axiom {id: $axiom_id}) + RETURN a.id as id + """ + result = client.execute_read(verify_query, {"axiom_id": str(axiom_id)}) + if not result: + raise ValueError(f"Axiom {axiom_id} not found") + + if force: + # Hard delete: remove axiom and all edges + delete_query = """ + MATCH (a:Axiom {id: $axiom_id}) + DETACH DELETE a + """ + client.execute_write(delete_query, {"axiom_id": str(axiom_id)}) + return { + "axiom_id": str(axiom_id), + "deleted": True, + "soft_delete": False, + } + else: + # Soft delete: mark as retconned + retcon_query = """ + MATCH (a:Axiom {id: $axiom_id}) + SET a.canon_level = $retconned_level + RETURN a + """ + client.execute_write( + retcon_query, + { + "axiom_id": str(axiom_id), + "retconned_level": CanonLevel.RETCONNED.value, + }, + ) + return { + "axiom_id": str(axiom_id), + "deleted": True, + "soft_delete": True, + } diff --git a/packages/data-layer/tests/test_tools/test_axiom_tools.py b/packages/data-layer/tests/test_tools/test_axiom_tools.py new file mode 100644 index 0000000..56c3e2a --- /dev/null +++ b/packages/data-layer/tests/test_tools/test_axiom_tools.py @@ -0,0 +1,712 @@ +""" +Unit tests for Neo4j axiom operations. + +Tests cover: +- neo4j_create_axiom +- neo4j_get_axiom +- neo4j_list_axioms +- neo4j_update_axiom +- neo4j_delete_axiom +""" + +from datetime import datetime +from typing import Dict, Any +from unittest.mock import Mock, patch +from uuid import UUID, uuid4 + +import pytest + +from monitor_data.schemas.axioms import ( + AxiomCreate, + AxiomUpdate, + AxiomFilter, +) +from monitor_data.schemas.base import CanonLevel, AxiomAuthority, AxiomDomain +from monitor_data.tools.neo4j_tools import ( + neo4j_create_axiom, + neo4j_get_axiom, + neo4j_list_axioms, + neo4j_update_axiom, + neo4j_delete_axiom, +) + + +# ============================================================================= +# TEST FIXTURES +# ============================================================================= + + +@pytest.fixture +def axiom_data(universe_data: Dict[str, Any]) -> Dict[str, Any]: + """Provide sample axiom data.""" + return { + "id": str(uuid4()), + "universe_id": universe_data["id"], + "statement": "Magic requires verbal components", + "domain": AxiomDomain.MAGIC.value, + "canon_level": CanonLevel.CANON.value, + "confidence": 1.0, + "authority": AxiomAuthority.GM.value, + "created_at": "2024-01-01T00:00:00", + } + + +@pytest.fixture +def source_data() -> Dict[str, Any]: + """Provide sample source data.""" + return { + "id": str(uuid4()), + "title": "Player's Handbook", + } + + +# ============================================================================= +# TESTS: neo4j_create_axiom +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_create_axiom_success( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + axiom_data: Dict[str, Any], +): + """Test successful axiom 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 axiom creation + mock_neo4j_client.execute_write.return_value = [{"a": axiom_data}] + + # Mock get_axiom response + from monitor_data.schemas.axioms import AxiomResponse + + expected_response = AxiomResponse( + id=UUID(axiom_data["id"]), + universe_id=UUID(axiom_data["universe_id"]), + statement=axiom_data["statement"], + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=axiom_data["confidence"], + authority=AxiomAuthority.GM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = expected_response + + # Create axiom + params = AxiomCreate( + universe_id=UUID(universe_data["id"]), + statement="Magic requires verbal components", + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=1.0, + authority=AxiomAuthority.GM, + ) + + result = neo4j_create_axiom(params) + + assert result.statement == "Magic requires verbal components" + assert result.domain == AxiomDomain.MAGIC + assert result.confidence == 1.0 + mock_neo4j_client.execute_write.assert_called() + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_create_axiom_invalid_universe( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test axiom creation with non-existent universe.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe doesn't exist + mock_neo4j_client.execute_read.return_value = [] + + params = AxiomCreate( + universe_id=uuid4(), + statement="Magic requires verbal components", + domain=AxiomDomain.MAGIC, + ) + + with pytest.raises(ValueError, match="Universe .* not found"): + neo4j_create_axiom(params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_create_axiom_with_provenance( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + axiom_data: Dict[str, Any], + source_data: Dict[str, Any], +): + """Test axiom creation with provenance (sources).""" + mock_get_client.return_value = mock_neo4j_client + + source_id = UUID(source_data["id"]) + + # Mock universe and source exist checks + mock_neo4j_client.execute_read.side_effect = [ + [{"id": universe_data["id"]}], # Universe exists + [{"id": source_data["id"]}], # Source exists + ] + + # Mock axiom creation + mock_neo4j_client.execute_write.return_value = [{"a": axiom_data}] + + # Mock get_axiom response with source + from monitor_data.schemas.axioms import AxiomResponse + + expected_response = AxiomResponse( + id=UUID(axiom_data["id"]), + universe_id=UUID(axiom_data["universe_id"]), + statement=axiom_data["statement"], + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=axiom_data["confidence"], + authority=AxiomAuthority.GM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[source_id], + snippet_ids=["snippet_123"], + ) + mock_get_axiom.return_value = expected_response + + # Create axiom with provenance + params = AxiomCreate( + universe_id=UUID(universe_data["id"]), + statement="Magic requires verbal components", + domain=AxiomDomain.MAGIC, + source_ids=[source_id], + snippet_ids=["snippet_123"], + ) + + result = neo4j_create_axiom(params) + + assert result.source_ids == [source_id] + assert result.snippet_ids == ["snippet_123"] + # Verify SUPPORTED_BY 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_axiom") +def test_create_axiom_invalid_source( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], +): + """Test axiom creation with non-existent source.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists but source doesn't + mock_neo4j_client.execute_read.side_effect = [ + [{"id": universe_data["id"]}], # Universe exists + [], # Source doesn't exist + ] + + params = AxiomCreate( + universe_id=UUID(universe_data["id"]), + statement="Magic requires verbal components", + domain=AxiomDomain.MAGIC, + source_ids=[uuid4()], + ) + + with pytest.raises(ValueError, match="Source .* not found"): + neo4j_create_axiom(params) + + +@pytest.mark.parametrize( + "domain", + [ + AxiomDomain.PHYSICS, + AxiomDomain.MAGIC, + AxiomDomain.SOCIETY, + AxiomDomain.METAPHYSICS, + ], +) +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_create_axiom_all_domains( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], + axiom_data: Dict[str, Any], + domain: AxiomDomain, +): + """Test axiom creation with each domain value.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock universe exists + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + + # Mock axiom creation + axiom_data_copy = axiom_data.copy() + axiom_data_copy["domain"] = domain.value + mock_neo4j_client.execute_write.return_value = [{"a": axiom_data_copy}] + + # Mock get_axiom response + from monitor_data.schemas.axioms import AxiomResponse + + expected_response = AxiomResponse( + id=UUID(axiom_data["id"]), + universe_id=UUID(axiom_data["universe_id"]), + statement=axiom_data["statement"], + domain=domain, + canon_level=CanonLevel.CANON, + confidence=axiom_data["confidence"], + authority=AxiomAuthority.GM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = expected_response + + # Create axiom with specific domain + params = AxiomCreate( + universe_id=UUID(universe_data["id"]), + statement=f"Test axiom for {domain.value}", + domain=domain, + ) + + result = neo4j_create_axiom(params) + + assert result.domain == domain + + +# ============================================================================= +# TESTS: neo4j_get_axiom +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_axiom_success( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], + source_data: Dict[str, Any], +): + """Test successful axiom retrieval with provenance.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom with source + mock_neo4j_client.execute_read.return_value = [ + { + "a": axiom_data, + "source_ids": [source_data["id"]], + } + ] + + result = neo4j_get_axiom(UUID(axiom_data["id"])) + + assert result is not None + assert result.id == UUID(axiom_data["id"]) + assert result.statement == axiom_data["statement"] + assert result.domain == AxiomDomain.MAGIC + assert result.source_ids == [UUID(source_data["id"])] + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_get_axiom_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test axiom retrieval when axiom doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom not found + mock_neo4j_client.execute_read.return_value = [] + + result = neo4j_get_axiom(uuid4()) + + assert result is None + + +# ============================================================================= +# TESTS: neo4j_list_axioms +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_axioms_no_filters( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test listing axioms without filters.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock multiple axioms + axiom_data_2 = axiom_data.copy() + axiom_data_2["id"] = str(uuid4()) + axiom_data_2["domain"] = AxiomDomain.PHYSICS.value + + mock_neo4j_client.execute_read.return_value = [ + {"a": axiom_data, "source_ids": []}, + {"a": axiom_data_2, "source_ids": []}, + ] + + result = neo4j_list_axioms() + + assert len(result) == 2 + assert result[0].domain == AxiomDomain.MAGIC + assert result[1].domain == AxiomDomain.PHYSICS + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_axioms_filter_by_domain( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test listing axioms filtered by domain.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom with MAGIC domain + mock_neo4j_client.execute_read.return_value = [ + {"a": axiom_data, "source_ids": []} + ] + + filters = AxiomFilter(domain=AxiomDomain.MAGIC) + result = neo4j_list_axioms(filters) + + assert len(result) == 1 + assert result[0].domain == AxiomDomain.MAGIC + # Verify query includes domain filter + call_args = mock_neo4j_client.execute_read.call_args + assert "a.domain = $domain" in call_args[0][0] + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_axioms_filter_by_universe( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], + universe_data: Dict[str, Any], +): + """Test listing axioms filtered by universe_id.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom for specific universe + mock_neo4j_client.execute_read.return_value = [ + {"a": axiom_data, "source_ids": []} + ] + + filters = AxiomFilter(universe_id=UUID(universe_data["id"])) + result = neo4j_list_axioms(filters) + + assert len(result) == 1 + assert result[0].universe_id == UUID(universe_data["id"]) + # Verify query includes universe_id filter + call_args = mock_neo4j_client.execute_read.call_args + assert "a.universe_id = $universe_id" in call_args[0][0] + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_list_axioms_filter_by_confidence( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test listing axioms filtered by confidence range.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock high confidence axiom + mock_neo4j_client.execute_read.return_value = [ + {"a": axiom_data, "source_ids": []} + ] + + filters = AxiomFilter(confidence_min=0.8, confidence_max=1.0) + result = neo4j_list_axioms(filters) + + assert len(result) == 1 + assert result[0].confidence >= 0.8 + # Verify query includes confidence filters + call_args = mock_neo4j_client.execute_read.call_args + assert "a.confidence >= $confidence_min" in call_args[0][0] + assert "a.confidence <= $confidence_max" in call_args[0][0] + + +# ============================================================================= +# TESTS: neo4j_update_axiom +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_update_axiom_success( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test successful axiom update.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom exists + mock_neo4j_client.execute_read.return_value = [{"id": axiom_data["id"]}] + + # Mock update + updated_data = axiom_data.copy() + updated_data["statement"] = "Magic requires verbal and somatic components" + updated_data["confidence"] = 0.9 + mock_neo4j_client.execute_write.return_value = [{"a": updated_data}] + + # Mock get_axiom response + from monitor_data.schemas.axioms import AxiomResponse + + expected_response = AxiomResponse( + id=UUID(updated_data["id"]), + universe_id=UUID(updated_data["universe_id"]), + statement=updated_data["statement"], + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=updated_data["confidence"], + authority=AxiomAuthority.GM, + created_at=datetime.fromisoformat(updated_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = expected_response + + # Update axiom + params = AxiomUpdate( + statement="Magic requires verbal and somatic components", + confidence=0.9, + ) + + result = neo4j_update_axiom(UUID(axiom_data["id"]), params) + + assert result.statement == "Magic requires verbal and somatic components" + assert result.confidence == 0.9 + mock_neo4j_client.execute_write.assert_called() + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_update_axiom_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test update axiom when axiom doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom not found + mock_neo4j_client.execute_read.return_value = [] + + params = AxiomUpdate(statement="Updated statement") + + with pytest.raises(ValueError, match="Axiom .* not found"): + neo4j_update_axiom(uuid4(), params) + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_update_axiom_no_changes( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test update axiom with no changes.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom exists + mock_neo4j_client.execute_read.return_value = [{"id": axiom_data["id"]}] + + # Mock get_axiom response (no update performed) + from monitor_data.schemas.axioms import AxiomResponse + + expected_response = AxiomResponse( + id=UUID(axiom_data["id"]), + universe_id=UUID(axiom_data["universe_id"]), + statement=axiom_data["statement"], + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=axiom_data["confidence"], + authority=AxiomAuthority.GM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = expected_response + + # Update with empty params + params = AxiomUpdate() + + result = neo4j_update_axiom(UUID(axiom_data["id"]), params) + + assert result.statement == axiom_data["statement"] + # No write should be performed for empty update + mock_neo4j_client.execute_write.assert_not_called() + + +# ============================================================================= +# TESTS: neo4j_delete_axiom +# ============================================================================= + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_axiom_soft_delete( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test axiom soft-delete (retconning).""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom exists + mock_neo4j_client.execute_read.return_value = [{"id": axiom_data["id"]}] + + # Mock retcon update + retconned_data = axiom_data.copy() + retconned_data["canon_level"] = CanonLevel.RETCONNED.value + mock_neo4j_client.execute_write.return_value = [{"a": retconned_data}] + + result = neo4j_delete_axiom(UUID(axiom_data["id"]), force=False) + + assert result["axiom_id"] == axiom_data["id"] + assert result["deleted"] is True + assert result["soft_delete"] is True + # Verify SET canon_level was called + call_args = mock_neo4j_client.execute_write.call_args + assert "SET a.canon_level" in call_args[0][0] + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_axiom_hard_delete( + mock_get_client: Mock, + mock_neo4j_client: Mock, + axiom_data: Dict[str, Any], +): + """Test axiom hard delete (permanent removal).""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom exists + mock_neo4j_client.execute_read.return_value = [{"id": axiom_data["id"]}] + + # Mock delete + mock_neo4j_client.execute_write.return_value = [] + + result = neo4j_delete_axiom(UUID(axiom_data["id"]), force=True) + + assert result["axiom_id"] == axiom_data["id"] + assert result["deleted"] is True + assert result["soft_delete"] is False + # Verify DETACH DELETE was called + call_args = mock_neo4j_client.execute_write.call_args + assert "DETACH DELETE" in call_args[0][0] + + +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +def test_delete_axiom_not_found( + mock_get_client: Mock, + mock_neo4j_client: Mock, +): + """Test delete axiom when axiom doesn't exist.""" + mock_get_client.return_value = mock_neo4j_client + + # Mock axiom not found + mock_neo4j_client.execute_read.return_value = [] + + with pytest.raises(ValueError, match="Axiom .* not found"): + neo4j_delete_axiom(uuid4()) + + +# ============================================================================= +# INTEGRATION TEST: Axiom Lifecycle +# ============================================================================= + + +@pytest.mark.integration +@patch("monitor_data.tools.neo4j_tools.get_neo4j_client") +@patch("monitor_data.tools.neo4j_tools.neo4j_get_axiom") +def test_axiom_lifecycle_integration( + mock_get_axiom: Mock, + mock_get_client: Mock, + mock_neo4j_client: Mock, + universe_data: Dict[str, Any], +): + """Test complete axiom lifecycle: create → update → soft-delete.""" + mock_get_client.return_value = mock_neo4j_client + + # Step 1: Create axiom + axiom_id = uuid4() + axiom_data = { + "id": str(axiom_id), + "universe_id": universe_data["id"], + "statement": "Magic exists", + "domain": AxiomDomain.MAGIC.value, + "canon_level": CanonLevel.CANON.value, + "confidence": 1.0, + "authority": AxiomAuthority.SYSTEM.value, + "created_at": "2024-01-01T00:00:00", + } + + # Mock create + mock_neo4j_client.execute_read.return_value = [{"id": universe_data["id"]}] + mock_neo4j_client.execute_write.return_value = [{"a": axiom_data}] + + from monitor_data.schemas.axioms import AxiomResponse + + created_response = AxiomResponse( + id=axiom_id, + universe_id=UUID(universe_data["id"]), + statement="Magic exists", + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=1.0, + authority=AxiomAuthority.SYSTEM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = created_response + + create_params = AxiomCreate( + universe_id=UUID(universe_data["id"]), + statement="Magic exists", + domain=AxiomDomain.MAGIC, + ) + created = neo4j_create_axiom(create_params) + assert created.statement == "Magic exists" + + # Step 2: Update axiom + mock_neo4j_client.execute_read.return_value = [{"id": str(axiom_id)}] + updated_data = axiom_data.copy() + updated_data["statement"] = "Magic exists with limitations" + updated_response = AxiomResponse( + id=axiom_id, + universe_id=UUID(universe_data["id"]), + statement="Magic exists with limitations", + domain=AxiomDomain.MAGIC, + canon_level=CanonLevel.CANON, + confidence=1.0, + authority=AxiomAuthority.SYSTEM, + created_at=datetime.fromisoformat(axiom_data["created_at"]), + source_ids=[], + snippet_ids=[], + ) + mock_get_axiom.return_value = updated_response + + update_params = AxiomUpdate(statement="Magic exists with limitations") + updated = neo4j_update_axiom(axiom_id, update_params) + assert updated.statement == "Magic exists with limitations" + + # Step 3: Soft-delete (retcon) + mock_neo4j_client.execute_read.return_value = [{"id": str(axiom_id)}] + retconned_data = updated_data.copy() + retconned_data["canon_level"] = CanonLevel.RETCONNED.value + mock_neo4j_client.execute_write.return_value = [{"a": retconned_data}] + + delete_result = neo4j_delete_axiom(axiom_id, force=False) + assert delete_result["soft_delete"] is True From 53ca742d57ab57fa67a6a4202cea1063143a3ec9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 02:16:47 +0000 Subject: [PATCH 3/3] Address code review feedback: optimize edge creation and clarify snippet handling Co-authored-by: spuentesp <112034353+spuentesp@users.noreply.github.com> --- .../src/monitor_data/schemas/axioms.py | 11 +++-- .../src/monitor_data/tools/neo4j_tools.py | 48 +++++++++++-------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/data-layer/src/monitor_data/schemas/axioms.py b/packages/data-layer/src/monitor_data/schemas/axioms.py index 9f8de65..a9d2a8c 100644 --- a/packages/data-layer/src/monitor_data/schemas/axioms.py +++ b/packages/data-layer/src/monitor_data/schemas/axioms.py @@ -41,7 +41,7 @@ class AxiomCreate(BaseModel): ) snippet_ids: Optional[List[str]] = Field( default=None, - description="Snippet IDs supporting this axiom (stored for reference)", + description="Snippet IDs from MongoDB (stored for reference only, not as Neo4j edges)", ) # Canonization metadata @@ -75,8 +75,13 @@ class AxiomResponse(BaseModel): created_at: datetime # Provenance data (populated by get operations) - source_ids: List[UUID] = Field(default_factory=list) - snippet_ids: List[str] = Field(default_factory=list) + source_ids: List[UUID] = Field( + default_factory=list, description="Neo4j Source UUIDs linked via SUPPORTED_BY" + ) + snippet_ids: List[str] = Field( + default_factory=list, + description="MongoDB Snippet IDs (stored in axiom metadata, not as Neo4j edges)" + ) model_config = {"from_attributes": True} 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 5d49b7e..e99e2ad 100644 --- a/packages/data-layer/src/monitor_data/tools/neo4j_tools.py +++ b/packages/data-layer/src/monitor_data/tools/neo4j_tools.py @@ -2432,18 +2432,21 @@ def neo4j_create_axiom(params: AxiomCreate) -> AxiomResponse: }, ) - # Create SUPPORTED_BY edges to sources + # Create SUPPORTED_BY edges to sources (batched for performance) if params.source_ids: source_edge_query = """ MATCH (a:Axiom {id: $axiom_id}) - MATCH (s:Source {id: $source_id}) + UNWIND $source_ids as source_id + MATCH (s:Source {id: source_id}) CREATE (a)-[:SUPPORTED_BY]->(s) """ - for source_id in params.source_ids: - client.execute_write( - source_edge_query, - {"axiom_id": str(axiom_id), "source_id": str(source_id)}, - ) + client.execute_write( + source_edge_query, + { + "axiom_id": str(axiom_id), + "source_ids": [str(sid) for sid in params.source_ids], + }, + ) # Retrieve with relationships axiom = neo4j_get_axiom(axiom_id) @@ -2491,7 +2494,7 @@ def neo4j_get_axiom(axiom_id: UUID) -> Optional[AxiomResponse]: authority=a["authority"], created_at=a["created_at"], source_ids=[UUID(sid) for sid in record["source_ids"] if sid], - snippet_ids=[], # Snippets not stored in Neo4j + snippet_ids=[], # Snippet IDs not stored in Neo4j (they're MongoDB references) ) @@ -2569,7 +2572,7 @@ def neo4j_list_axioms(filters: Optional[AxiomFilter] = None) -> List[AxiomRespon authority=a["authority"], created_at=a["created_at"], source_ids=[UUID(sid) for sid in record["source_ids"] if sid], - snippet_ids=[], # Snippets not stored in Neo4j + snippet_ids=[], # Snippet IDs not stored in Neo4j (they're MongoDB references) ) ) @@ -2595,16 +2598,7 @@ def neo4j_update_axiom(axiom_id: UUID, params: AxiomUpdate) -> AxiomResponse: """ client = get_neo4j_client() - # Verify axiom exists - verify_query = """ - MATCH (a:Axiom {id: $axiom_id}) - RETURN a.id as id - """ - result = client.execute_read(verify_query, {"axiom_id": str(axiom_id)}) - if not result: - raise ValueError(f"Axiom {axiom_id} not found") - - # Build SET clause for only provided fields + # Build SET clause for only provided fields (early check before DB access) set_clauses = [] update_params = {"axiom_id": str(axiom_id)} @@ -2621,8 +2615,20 @@ def neo4j_update_axiom(axiom_id: UUID, params: AxiomUpdate) -> AxiomResponse: update_params["confidence"] = params.confidence if not set_clauses: - # No updates provided, just return current axiom - return neo4j_get_axiom(axiom_id) + # No updates provided, return current axiom without DB write + axiom = neo4j_get_axiom(axiom_id) + if axiom is None: + raise ValueError(f"Axiom {axiom_id} not found") + return axiom + + # Verify axiom exists before updating + verify_query = """ + MATCH (a:Axiom {id: $axiom_id}) + RETURN a.id as id + """ + result = client.execute_read(verify_query, {"axiom_id": str(axiom_id)}) + if not result: + raise ValueError(f"Axiom {axiom_id} not found") set_clause = "SET " + ", ".join(set_clauses)