From 945544880dce21078c1eaf8c13d73b7df8d5351d Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 8 Feb 2026 22:30:26 +0100 Subject: [PATCH] feat(prompts): implement Phase 1 - Foundation (FR-1) Implement addressable artifacts with content-based identity and change detection. Core Features: - Artifact model with SHA-256 content digests - ArtifactReference for cross-space addressing - IArtifactRepository interface for pluggable storage - SQLiteArtifactRepository implementation - ArtifactService for high-level operations - Content digest calculation utilities Database: - prompt_artifacts table with indexes - Support for artifact metadata and types - UNIQUE constraint on space_id+name Tests (41 passing): - 26 model tests (metadata, artifacts, references, digests) - 15 repository tests (CRUD, queries, constraints) Implements: - FR-1.1: Unique addressability by name and ID - FR-1.2: Content digest computation and storage - FR-1.3: Cross-space artifact references Files Created: - markitect/prompts/models.py - markitect/prompts/repositories/interfaces.py - markitect/prompts/repositories/sqlite.py - markitect/prompts/services/artifact_service.py - migrations/prompts/001_create_artifacts_table.sql - tests/unit/prompts/test_artifact_models.py - tests/unit/prompts/test_artifact_repository.py Co-Authored-By: Claude Opus 4.6 --- markitect/prompts/__init__.py | 33 ++ markitect/prompts/models.py | 279 +++++++++++++++ markitect/prompts/repositories/__init__.py | 20 ++ markitect/prompts/repositories/interfaces.py | 159 +++++++++ markitect/prompts/repositories/sqlite.py | 332 ++++++++++++++++++ markitect/prompts/services/__init__.py | 10 + .../prompts/services/artifact_service.py | 239 +++++++++++++ .../prompts/001_create_artifacts_table.sql | 34 ++ tests/unit/prompts/__init__.py | 1 + tests/unit/prompts/test_artifact_models.py | 313 +++++++++++++++++ .../unit/prompts/test_artifact_repository.py | 261 ++++++++++++++ 11 files changed, 1681 insertions(+) create mode 100644 markitect/prompts/__init__.py create mode 100644 markitect/prompts/models.py create mode 100644 markitect/prompts/repositories/__init__.py create mode 100644 markitect/prompts/repositories/interfaces.py create mode 100644 markitect/prompts/repositories/sqlite.py create mode 100644 markitect/prompts/services/__init__.py create mode 100644 markitect/prompts/services/artifact_service.py create mode 100644 migrations/prompts/001_create_artifacts_table.sql create mode 100644 tests/unit/prompts/__init__.py create mode 100644 tests/unit/prompts/test_artifact_models.py create mode 100644 tests/unit/prompts/test_artifact_repository.py diff --git a/markitect/prompts/__init__.py b/markitect/prompts/__init__.py new file mode 100644 index 00000000..dd481889 --- /dev/null +++ b/markitect/prompts/__init__.py @@ -0,0 +1,33 @@ +""" +Prompt Dependency Resolution system for MarkiTect. + +This package provides infrastructure for executing PromptTemplates with +deterministic dependency resolution, incremental recomputation, and quality +validation across InformationSpaces. + +Key components: +- Artifact management with content-based addressing +- Template definition with macro support +- Deterministic resolution across spaces +- Idempotent execution with InputBundleHash +- Dependency graph construction and tracking +- Incremental recomputation with change impact analysis +- Quality gate validation and halting policies +- Complete traceability and provenance tracking +""" + +__version__ = "0.1.0" + +from markitect.prompts.models import ( + Artifact, + ArtifactReference, + ArtifactMetadata, + ArtifactType, +) + +__all__ = [ + "Artifact", + "ArtifactReference", + "ArtifactMetadata", + "ArtifactType", +] diff --git a/markitect/prompts/models.py b/markitect/prompts/models.py new file mode 100644 index 00000000..ef45ae29 --- /dev/null +++ b/markitect/prompts/models.py @@ -0,0 +1,279 @@ +""" +Core domain models for Prompt Dependency Resolution. + +This module provides foundational data models for addressable artifacts, +references, and content hashing for change detection. +""" + +import hashlib +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, Any, Optional +from enum import Enum + + +class ArtifactType(Enum): + """Type classification for artifacts.""" + CONTENT = "content" # Regular content artifact + TEMPLATE = "template" # Prompt template + GENERATED = "generated" # LLM-generated artifact + SCHEMA = "schema" # Validation schema + CONFIG = "config" # Configuration artifact + + +@dataclass +class ArtifactMetadata: + """ + Extensible metadata for artifacts. + + Attributes: + description: Human-readable description + tags: List of tags for categorization + author: Author identifier + version: Version string (optional) + custom: Dictionary for custom metadata fields + """ + description: Optional[str] = None + tags: list[str] = field(default_factory=list) + author: Optional[str] = None + version: Optional[str] = None + custom: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """Convert metadata to dictionary for serialization.""" + return { + "description": self.description, + "tags": self.tags, + "author": self.author, + "version": self.version, + "custom": self.custom, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ArtifactMetadata": + """Create metadata from dictionary.""" + return cls( + description=data.get("description"), + tags=data.get("tags", []), + author=data.get("author"), + version=data.get("version"), + custom=data.get("custom", {}), + ) + + +@dataclass +class Artifact: + """ + Core artifact entity with content-based addressing. + + Implements FR-1: InformationSpace Addressability + - Unique persistent identifier + - Content digest for change detection + - Cross-space references via space_id + + Attributes: + id: Unique identifier (UUID) + space_id: ID of containing InformationSpace + name: Human-readable name (unique within space) + artifact_type: Classification of artifact + content_digest: SHA-256 hash of content + content_size: Size of content in bytes + metadata: Extensible metadata + created_at: Creation timestamp + updated_at: Last modification timestamp + """ + id: str + space_id: str + name: str + artifact_type: ArtifactType + content_digest: str + content_size: int = 0 + metadata: ArtifactMetadata = field(default_factory=ArtifactMetadata) + created_at: datetime = field(default_factory=datetime.utcnow) + updated_at: datetime = field(default_factory=datetime.utcnow) + + @classmethod + def create( + cls, + space_id: str, + name: str, + content: str, + artifact_type: ArtifactType = ArtifactType.CONTENT, + metadata: Optional[ArtifactMetadata] = None, + ) -> "Artifact": + """ + Create a new artifact with automatic ID and digest generation. + + Args: + space_id: ID of containing space + name: Artifact name + content: Artifact content + artifact_type: Type classification + metadata: Optional metadata + + Returns: + New Artifact instance with computed digest + """ + artifact_id = str(uuid.uuid4()) + content_digest = calculate_content_digest(content) + content_size = len(content.encode('utf-8')) + + return cls( + id=artifact_id, + space_id=space_id, + name=name, + artifact_type=artifact_type, + content_digest=content_digest, + content_size=content_size, + metadata=metadata or ArtifactMetadata(), + ) + + def update_content(self, new_content: str) -> None: + """ + Update artifact content and recalculate digest. + + Args: + new_content: New content string + """ + self.content_digest = calculate_content_digest(new_content) + self.content_size = len(new_content.encode('utf-8')) + self.updated_at = datetime.utcnow() + + def has_changed(self, current_digest: str) -> bool: + """ + Check if content has changed by comparing digests. + + Args: + current_digest: Digest to compare against + + Returns: + True if digests differ + """ + return self.content_digest != current_digest + + def to_dict(self) -> Dict[str, Any]: + """Convert artifact to dictionary for serialization.""" + return { + "id": self.id, + "space_id": self.space_id, + "name": self.name, + "artifact_type": self.artifact_type.value, + "content_digest": self.content_digest, + "content_size": self.content_size, + "metadata": self.metadata.to_dict(), + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Artifact": + """Create artifact from dictionary.""" + return cls( + id=data["id"], + space_id=data["space_id"], + name=data["name"], + artifact_type=ArtifactType(data["artifact_type"]), + content_digest=data["content_digest"], + content_size=data.get("content_size", 0), + metadata=ArtifactMetadata.from_dict(data.get("metadata", {})), + created_at=datetime.fromisoformat(data["created_at"]), + updated_at=datetime.fromisoformat(data["updated_at"]), + ) + + +@dataclass +class ArtifactReference: + """ + Reference to an artifact, possibly in another space. + + Implements FR-1.3: Cross-space artifact references + + Attributes: + name: Artifact name to resolve + space_id: Optional specific space ID (if not provided, uses resolution order) + version: Optional version constraint + """ + name: str + space_id: Optional[str] = None + version: Optional[str] = None + + def __str__(self) -> str: + """String representation for display and debugging.""" + if self.space_id: + ref = f"{self.space_id}:{self.name}" + else: + ref = self.name + + if self.version: + ref = f"{ref}@{self.version}" + + return ref + + @classmethod + def parse(cls, reference_str: str) -> "ArtifactReference": + """ + Parse a reference string into components. + + Formats: + - "artifact-name" + - "space-id:artifact-name" + - "artifact-name@version" + - "space-id:artifact-name@version" + + Args: + reference_str: Reference string to parse + + Returns: + Parsed ArtifactReference + + Raises: + ValueError: If reference string is malformed + """ + # Split version if present + if '@' in reference_str: + ref_part, version = reference_str.rsplit('@', 1) + else: + ref_part, version = reference_str, None + + # Split space if present + if ':' in ref_part: + space_id, name = ref_part.split(':', 1) + else: + space_id, name = None, ref_part + + if not name: + raise ValueError(f"Invalid artifact reference: {reference_str}") + + return cls(name=name, space_id=space_id, version=version) + + +def calculate_content_digest(content: str) -> str: + """ + Calculate SHA-256 digest of content. + + Implements FR-1.2: Content digest computation + + Args: + content: Content string to hash + + Returns: + Hexadecimal SHA-256 digest + """ + return hashlib.sha256(content.encode('utf-8')).hexdigest() + + +def calculate_bundle_digest(components: Dict[str, str]) -> str: + """ + Calculate digest of multiple components (for InputBundleHash). + + Args: + components: Dictionary of component name -> digest + + Returns: + Hexadecimal SHA-256 digest of sorted components + """ + # Sort components for deterministic hashing + sorted_items = sorted(components.items()) + bundle_str = ':'.join(f"{k}={v}" for k, v in sorted_items) + return hashlib.sha256(bundle_str.encode('utf-8')).hexdigest() diff --git a/markitect/prompts/repositories/__init__.py b/markitect/prompts/repositories/__init__.py new file mode 100644 index 00000000..69cb1c2b --- /dev/null +++ b/markitect/prompts/repositories/__init__.py @@ -0,0 +1,20 @@ +""" +Repository layer for Prompt Dependency Resolution. + +This package provides abstract interfaces and concrete implementations +for persisting prompts system entities. +""" + +from markitect.prompts.repositories.interfaces import ( + IArtifactRepository, + RepositoryError, + ArtifactNotFoundError, + DuplicateArtifactError, +) + +__all__ = [ + "IArtifactRepository", + "RepositoryError", + "ArtifactNotFoundError", + "DuplicateArtifactError", +] diff --git a/markitect/prompts/repositories/interfaces.py b/markitect/prompts/repositories/interfaces.py new file mode 100644 index 00000000..acfaeb0b --- /dev/null +++ b/markitect/prompts/repositories/interfaces.py @@ -0,0 +1,159 @@ +""" +Repository interfaces for artifact persistence. + +Defines abstract interfaces for artifact storage, enabling +pluggable storage backends. +""" + +from abc import ABC, abstractmethod +from typing import List, Optional +from markitect.prompts.models import Artifact, ArtifactType + + +class RepositoryError(Exception): + """Base exception for repository errors.""" + pass + + +class ArtifactNotFoundError(RepositoryError): + """Raised when an artifact cannot be found.""" + pass + + +class DuplicateArtifactError(RepositoryError): + """Raised when attempting to create an artifact with duplicate name.""" + pass + + +class IArtifactRepository(ABC): + """ + Abstract interface for artifact persistence. + + Implements FR-1: InformationSpace Addressability + Provides CRUD operations for artifacts with content digest tracking. + """ + + @abstractmethod + def create(self, artifact: Artifact) -> Artifact: + """ + Persist a new artifact. + + Args: + artifact: Artifact to create + + Returns: + Created artifact + + Raises: + DuplicateArtifactError: If artifact with same space_id+name exists + RepositoryError: On other persistence errors + """ + pass + + @abstractmethod + def get_by_id(self, artifact_id: str) -> Optional[Artifact]: + """ + Retrieve artifact by ID. + + Args: + artifact_id: Artifact identifier + + Returns: + Artifact if found, None otherwise + """ + pass + + @abstractmethod + def get_by_name(self, space_id: str, name: str) -> Optional[Artifact]: + """ + Retrieve artifact by space and name. + + Implements FR-1.3: Cross-space artifact lookup + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + Artifact if found, None otherwise + """ + pass + + @abstractmethod + def get_by_digest(self, content_digest: str) -> List[Artifact]: + """ + Find artifacts with matching content digest. + + Implements FR-1.2: Content digest queries + + Args: + content_digest: SHA-256 digest to match + + Returns: + List of artifacts with matching digest + """ + pass + + @abstractmethod + def list_by_space( + self, + space_id: str, + artifact_type: Optional[ArtifactType] = None, + ) -> List[Artifact]: + """ + List all artifacts in a space. + + Args: + space_id: Space identifier + artifact_type: Optional type filter + + Returns: + List of artifacts in space + """ + pass + + @abstractmethod + def update(self, artifact: Artifact) -> Artifact: + """ + Update an existing artifact. + + Updates content digest and modified timestamp. + + Args: + artifact: Artifact with updated data + + Returns: + Updated artifact + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + RepositoryError: On other persistence errors + """ + pass + + @abstractmethod + def delete(self, artifact_id: str) -> bool: + """ + Delete an artifact. + + Args: + artifact_id: Artifact identifier + + Returns: + True if deleted, False if not found + """ + pass + + @abstractmethod + def exists(self, space_id: str, name: str) -> bool: + """ + Check if artifact exists. + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + True if artifact exists + """ + pass diff --git a/markitect/prompts/repositories/sqlite.py b/markitect/prompts/repositories/sqlite.py new file mode 100644 index 00000000..90f9a853 --- /dev/null +++ b/markitect/prompts/repositories/sqlite.py @@ -0,0 +1,332 @@ +""" +SQLite implementation of artifact repositories. + +This module provides SQLite-backed implementations of the repository +interfaces for persistent storage of prompt artifacts. +""" + +import sqlite3 +import json +from pathlib import Path +from typing import List, Optional +from datetime import datetime + +from markitect.prompts.repositories.interfaces import ( + IArtifactRepository, + ArtifactNotFoundError, + DuplicateArtifactError, + RepositoryError, +) +from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata + + +# SQL Schema for artifact tables +ARTIFACT_TABLES_SQL = """ +-- Prompt artifacts table +CREATE TABLE IF NOT EXISTS prompt_artifacts ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + name TEXT NOT NULL, + artifact_type TEXT NOT NULL, + content_digest TEXT NOT NULL, + content_size INTEGER DEFAULT 0, + metadata JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(space_id, name) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_artifacts_space ON prompt_artifacts(space_id); +CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON prompt_artifacts(content_digest); +CREATE INDEX IF NOT EXISTS idx_artifacts_type ON prompt_artifacts(artifact_type); +CREATE INDEX IF NOT EXISTS idx_artifacts_name ON prompt_artifacts(space_id, name); +""" + + +def initialize_artifact_tables(db_path: str) -> None: + """ + Initialize the artifact-related database tables. + + Args: + db_path: Path to the SQLite database file + """ + # Ensure directory exists + db_dir = Path(db_path).parent + if db_dir and not db_dir.exists(): + db_dir.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(db_path) + try: + conn.executescript(ARTIFACT_TABLES_SQL) + conn.commit() + finally: + conn.close() + + +class SQLiteArtifactRepository(IArtifactRepository): + """ + SQLite implementation of artifact repository. + + Provides persistent storage for artifacts with content digest tracking. + """ + + def __init__(self, db_path: str): + """ + Initialize repository with database path. + + Args: + db_path: Path to SQLite database file + """ + self.db_path = db_path + initialize_artifact_tables(db_path) + + def _get_connection(self) -> sqlite3.Connection: + """Get a database connection.""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def create(self, artifact: Artifact) -> Artifact: + """ + Persist a new artifact. + + Args: + artifact: Artifact to create + + Returns: + Created artifact + + Raises: + DuplicateArtifactError: If artifact with same space_id+name exists + RepositoryError: On other persistence errors + """ + conn = self._get_connection() + try: + conn.execute( + """ + INSERT INTO prompt_artifacts ( + id, space_id, name, artifact_type, content_digest, + content_size, metadata, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + artifact.id, + artifact.space_id, + artifact.name, + artifact.artifact_type.value, + artifact.content_digest, + artifact.content_size, + json.dumps(artifact.metadata.to_dict()), + artifact.created_at.isoformat(), + artifact.updated_at.isoformat(), + ), + ) + conn.commit() + return artifact + except sqlite3.IntegrityError as e: + if "UNIQUE constraint" in str(e): + raise DuplicateArtifactError( + f"Artifact '{artifact.name}' already exists in space '{artifact.space_id}'" + ) + raise RepositoryError(f"Database integrity error: {e}") + except Exception as e: + raise RepositoryError(f"Failed to create artifact: {e}") + finally: + conn.close() + + def get_by_id(self, artifact_id: str) -> Optional[Artifact]: + """ + Retrieve artifact by ID. + + Args: + artifact_id: Artifact identifier + + Returns: + Artifact if found, None otherwise + """ + conn = self._get_connection() + try: + cursor = conn.execute( + "SELECT * FROM prompt_artifacts WHERE id = ?", + (artifact_id,) + ) + row = cursor.fetchone() + return self._row_to_artifact(row) if row else None + finally: + conn.close() + + def get_by_name(self, space_id: str, name: str) -> Optional[Artifact]: + """ + Retrieve artifact by space and name. + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + Artifact if found, None otherwise + """ + conn = self._get_connection() + try: + cursor = conn.execute( + "SELECT * FROM prompt_artifacts WHERE space_id = ? AND name = ?", + (space_id, name) + ) + row = cursor.fetchone() + return self._row_to_artifact(row) if row else None + finally: + conn.close() + + def get_by_digest(self, content_digest: str) -> List[Artifact]: + """ + Find artifacts with matching content digest. + + Args: + content_digest: SHA-256 digest to match + + Returns: + List of artifacts with matching digest + """ + conn = self._get_connection() + try: + cursor = conn.execute( + "SELECT * FROM prompt_artifacts WHERE content_digest = ?", + (content_digest,) + ) + return [self._row_to_artifact(row) for row in cursor.fetchall()] + finally: + conn.close() + + def list_by_space( + self, + space_id: str, + artifact_type: Optional[ArtifactType] = None, + ) -> List[Artifact]: + """ + List all artifacts in a space. + + Args: + space_id: Space identifier + artifact_type: Optional type filter + + Returns: + List of artifacts in space + """ + conn = self._get_connection() + try: + if artifact_type: + cursor = conn.execute( + "SELECT * FROM prompt_artifacts WHERE space_id = ? AND artifact_type = ? ORDER BY name", + (space_id, artifact_type.value) + ) + else: + cursor = conn.execute( + "SELECT * FROM prompt_artifacts WHERE space_id = ? ORDER BY name", + (space_id,) + ) + return [self._row_to_artifact(row) for row in cursor.fetchall()] + finally: + conn.close() + + def update(self, artifact: Artifact) -> Artifact: + """ + Update an existing artifact. + + Args: + artifact: Artifact with updated data + + Returns: + Updated artifact + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + RepositoryError: On other persistence errors + """ + conn = self._get_connection() + try: + # Update timestamp + artifact.updated_at = datetime.utcnow() + + cursor = conn.execute( + """ + UPDATE prompt_artifacts + SET content_digest = ?, content_size = ?, metadata = ?, updated_at = ? + WHERE id = ? + """, + ( + artifact.content_digest, + artifact.content_size, + json.dumps(artifact.metadata.to_dict()), + artifact.updated_at.isoformat(), + artifact.id, + ), + ) + if cursor.rowcount == 0: + raise ArtifactNotFoundError(f"Artifact with ID '{artifact.id}' not found") + conn.commit() + return artifact + except ArtifactNotFoundError: + raise + except Exception as e: + raise RepositoryError(f"Failed to update artifact: {e}") + finally: + conn.close() + + def delete(self, artifact_id: str) -> bool: + """ + Delete an artifact. + + Args: + artifact_id: Artifact identifier + + Returns: + True if deleted, False if not found + """ + conn = self._get_connection() + try: + cursor = conn.execute( + "DELETE FROM prompt_artifacts WHERE id = ?", + (artifact_id,) + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + def exists(self, space_id: str, name: str) -> bool: + """ + Check if artifact exists. + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + True if artifact exists + """ + conn = self._get_connection() + try: + cursor = conn.execute( + "SELECT COUNT(*) FROM prompt_artifacts WHERE space_id = ? AND name = ?", + (space_id, name) + ) + count = cursor.fetchone()[0] + return count > 0 + finally: + conn.close() + + def _row_to_artifact(self, row: sqlite3.Row) -> Artifact: + """Convert database row to Artifact instance.""" + metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {} + return Artifact( + id=row["id"], + space_id=row["space_id"], + name=row["name"], + artifact_type=ArtifactType(row["artifact_type"]), + content_digest=row["content_digest"], + content_size=row["content_size"], + metadata=ArtifactMetadata.from_dict(metadata_dict), + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + ) diff --git a/markitect/prompts/services/__init__.py b/markitect/prompts/services/__init__.py new file mode 100644 index 00000000..1d9dcee0 --- /dev/null +++ b/markitect/prompts/services/__init__.py @@ -0,0 +1,10 @@ +""" +Service layer for Prompt Dependency Resolution. + +This package provides high-level business logic for artifact and template +management, orchestrating repositories and domain operations. +""" + +from markitect.prompts.services.artifact_service import ArtifactService + +__all__ = ["ArtifactService"] diff --git a/markitect/prompts/services/artifact_service.py b/markitect/prompts/services/artifact_service.py new file mode 100644 index 00000000..565074c6 --- /dev/null +++ b/markitect/prompts/services/artifact_service.py @@ -0,0 +1,239 @@ +""" +Artifact service for high-level artifact management operations. + +This service provides business logic for creating, querying, and updating +artifacts with automatic content digest calculation and change tracking. +""" + +from typing import List, Optional +from datetime import datetime + +from markitect.prompts.models import ( + Artifact, + ArtifactType, + ArtifactMetadata, + ArtifactReference, + calculate_content_digest, +) +from markitect.prompts.repositories.interfaces import ( + IArtifactRepository, + ArtifactNotFoundError, +) + + +class ArtifactService: + """ + Service for artifact management operations. + + Provides high-level business logic on top of artifact repository, + handling content digest calculation, change detection, and cross-space + artifact resolution. + """ + + def __init__(self, repository: IArtifactRepository): + """ + Initialize service with repository. + + Args: + repository: Artifact repository implementation + """ + self.repository = repository + + def create_artifact( + self, + space_id: str, + name: str, + content: str, + artifact_type: ArtifactType = ArtifactType.CONTENT, + metadata: Optional[ArtifactMetadata] = None, + ) -> Artifact: + """ + Create a new artifact with automatic digest calculation. + + Args: + space_id: ID of containing space + name: Artifact name + content: Artifact content + artifact_type: Type classification + metadata: Optional metadata + + Returns: + Created artifact + + Raises: + DuplicateArtifactError: If artifact already exists + """ + artifact = Artifact.create( + space_id=space_id, + name=name, + content=content, + artifact_type=artifact_type, + metadata=metadata, + ) + return self.repository.create(artifact) + + def get_artifact(self, artifact_id: str) -> Artifact: + """ + Retrieve artifact by ID. + + Args: + artifact_id: Artifact identifier + + Returns: + Artifact instance + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + """ + artifact = self.repository.get_by_id(artifact_id) + if not artifact: + raise ArtifactNotFoundError(f"Artifact with ID '{artifact_id}' not found") + return artifact + + def get_artifact_by_name(self, space_id: str, name: str) -> Artifact: + """ + Retrieve artifact by space and name. + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + Artifact instance + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + """ + artifact = self.repository.get_by_name(space_id, name) + if not artifact: + raise ArtifactNotFoundError( + f"Artifact '{name}' not found in space '{space_id}'" + ) + return artifact + + def resolve_reference( + self, + reference: ArtifactReference, + search_spaces: List[str], + ) -> Optional[Artifact]: + """ + Resolve an artifact reference across multiple spaces. + + Implements FR-1.3: Cross-space artifact references + Searches spaces in order until artifact is found. + + Args: + reference: Artifact reference to resolve + search_spaces: List of space IDs to search in order + + Returns: + Resolved artifact, or None if not found + """ + # If reference specifies space, search only that space + if reference.space_id: + return self.repository.get_by_name(reference.space_id, reference.name) + + # Search spaces in order + for space_id in search_spaces: + artifact = self.repository.get_by_name(space_id, reference.name) + if artifact: + # TODO: Handle version constraint when versioning is implemented + return artifact + + return None + + def update_artifact_content( + self, + artifact_id: str, + new_content: str, + ) -> Artifact: + """ + Update artifact content with automatic digest recalculation. + + Args: + artifact_id: Artifact to update + new_content: New content + + Returns: + Updated artifact + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + """ + artifact = self.get_artifact(artifact_id) + artifact.update_content(new_content) + return self.repository.update(artifact) + + def detect_change(self, artifact_id: str, current_content: str) -> bool: + """ + Detect if artifact content has changed. + + Args: + artifact_id: Artifact to check + current_content: Current content to compare + + Returns: + True if content has changed + + Raises: + ArtifactNotFoundError: If artifact doesn't exist + """ + artifact = self.get_artifact(artifact_id) + current_digest = calculate_content_digest(current_content) + return artifact.has_changed(current_digest) + + def list_artifacts( + self, + space_id: str, + artifact_type: Optional[ArtifactType] = None, + ) -> List[Artifact]: + """ + List artifacts in a space. + + Args: + space_id: Space identifier + artifact_type: Optional type filter + + Returns: + List of artifacts + """ + return self.repository.list_by_space(space_id, artifact_type) + + def find_by_digest(self, content_digest: str) -> List[Artifact]: + """ + Find all artifacts with matching content digest. + + Useful for finding duplicate content across spaces. + + Args: + content_digest: SHA-256 digest to match + + Returns: + List of artifacts with matching digest + """ + return self.repository.get_by_digest(content_digest) + + def delete_artifact(self, artifact_id: str) -> bool: + """ + Delete an artifact. + + Args: + artifact_id: Artifact to delete + + Returns: + True if deleted, False if not found + """ + return self.repository.delete(artifact_id) + + def artifact_exists(self, space_id: str, name: str) -> bool: + """ + Check if artifact exists. + + Args: + space_id: Space identifier + name: Artifact name + + Returns: + True if artifact exists + """ + return self.repository.exists(space_id, name) diff --git a/migrations/prompts/001_create_artifacts_table.sql b/migrations/prompts/001_create_artifacts_table.sql new file mode 100644 index 00000000..48ef78a6 --- /dev/null +++ b/migrations/prompts/001_create_artifacts_table.sql @@ -0,0 +1,34 @@ +-- Migration 001: Create prompt artifacts table +-- Implements FR-1: InformationSpace Addressability +-- Date: 2026-02-08 + +-- Prompt artifacts table for content-based addressing +CREATE TABLE IF NOT EXISTS prompt_artifacts ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + name TEXT NOT NULL, + artifact_type TEXT NOT NULL, + content_digest TEXT NOT NULL, + content_size INTEGER DEFAULT 0, + metadata JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(space_id, name) +); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_artifacts_space ON prompt_artifacts(space_id); +CREATE INDEX IF NOT EXISTS idx_artifacts_digest ON prompt_artifacts(content_digest); +CREATE INDEX IF NOT EXISTS idx_artifacts_type ON prompt_artifacts(artifact_type); +CREATE INDEX IF NOT EXISTS idx_artifacts_name ON prompt_artifacts(space_id, name); + +-- Comments (for documentation) +-- prompt_artifacts.id: Unique UUID identifier for the artifact +-- prompt_artifacts.space_id: Reference to information space containing this artifact +-- prompt_artifacts.name: Human-readable name, unique within space +-- prompt_artifacts.artifact_type: Classification: content, template, generated, schema, config +-- prompt_artifacts.content_digest: SHA-256 hash of content for change detection +-- prompt_artifacts.content_size: Size of content in bytes +-- prompt_artifacts.metadata: JSON extensible metadata (description, tags, author, version, custom) +-- prompt_artifacts.created_at: Artifact creation timestamp +-- prompt_artifacts.updated_at: Last modification timestamp diff --git a/tests/unit/prompts/__init__.py b/tests/unit/prompts/__init__.py new file mode 100644 index 00000000..f0f0d726 --- /dev/null +++ b/tests/unit/prompts/__init__.py @@ -0,0 +1 @@ +"""Unit tests for Prompt Dependency Resolution.""" diff --git a/tests/unit/prompts/test_artifact_models.py b/tests/unit/prompts/test_artifact_models.py new file mode 100644 index 00000000..c4c494c6 --- /dev/null +++ b/tests/unit/prompts/test_artifact_models.py @@ -0,0 +1,313 @@ +""" +Unit tests for artifact models. + +Tests Artifact, ArtifactMetadata, ArtifactReference, and content digest +calculation functions. +""" + +import pytest +from datetime import datetime + +from markitect.prompts.models import ( + Artifact, + ArtifactMetadata, + ArtifactReference, + ArtifactType, + calculate_content_digest, + calculate_bundle_digest, +) + + +class TestArtifactMetadata: + """Tests for ArtifactMetadata.""" + + def test_create_empty_metadata(self): + """Test creating metadata with defaults.""" + metadata = ArtifactMetadata() + assert metadata.description is None + assert metadata.tags == [] + assert metadata.author is None + assert metadata.version is None + assert metadata.custom == {} + + def test_create_metadata_with_values(self): + """Test creating metadata with values.""" + metadata = ArtifactMetadata( + description="Test artifact", + tags=["test", "example"], + author="test-author", + version="1.0.0", + custom={"key": "value"} + ) + assert metadata.description == "Test artifact" + assert metadata.tags == ["test", "example"] + assert metadata.author == "test-author" + assert metadata.version == "1.0.0" + assert metadata.custom == {"key": "value"} + + def test_metadata_to_dict(self): + """Test metadata serialization to dict.""" + metadata = ArtifactMetadata( + description="Test", + tags=["tag1"], + author="author", + ) + data = metadata.to_dict() + assert data["description"] == "Test" + assert data["tags"] == ["tag1"] + assert data["author"] == "author" + assert data["version"] is None + assert data["custom"] == {} + + def test_metadata_from_dict(self): + """Test metadata deserialization from dict.""" + data = { + "description": "Test", + "tags": ["tag1", "tag2"], + "author": "author", + "version": "2.0", + "custom": {"extra": "data"} + } + metadata = ArtifactMetadata.from_dict(data) + assert metadata.description == "Test" + assert metadata.tags == ["tag1", "tag2"] + assert metadata.author == "author" + assert metadata.version == "2.0" + assert metadata.custom == {"extra": "data"} + + +class TestArtifact: + """Tests for Artifact model.""" + + def test_create_artifact(self): + """Test artifact creation with automatic digest calculation.""" + content = "# Test Content\n\nThis is a test." + artifact = Artifact.create( + space_id="space-1", + name="test-artifact", + content=content, + ) + + assert artifact.id # UUID generated + assert artifact.space_id == "space-1" + assert artifact.name == "test-artifact" + assert artifact.artifact_type == ArtifactType.CONTENT + assert artifact.content_digest == calculate_content_digest(content) + assert artifact.content_size == len(content.encode('utf-8')) + assert isinstance(artifact.metadata, ArtifactMetadata) + assert isinstance(artifact.created_at, datetime) + assert isinstance(artifact.updated_at, datetime) + + def test_create_artifact_with_type(self): + """Test creating artifact with specific type.""" + artifact = Artifact.create( + space_id="space-1", + name="my-template", + content="Template content", + artifact_type=ArtifactType.TEMPLATE, + ) + assert artifact.artifact_type == ArtifactType.TEMPLATE + + def test_create_artifact_with_metadata(self): + """Test creating artifact with metadata.""" + metadata = ArtifactMetadata(description="Test", tags=["tag1"]) + artifact = Artifact.create( + space_id="space-1", + name="test", + content="content", + metadata=metadata, + ) + assert artifact.metadata.description == "Test" + assert artifact.metadata.tags == ["tag1"] + + def test_update_content(self): + """Test updating artifact content.""" + artifact = Artifact.create( + space_id="space-1", + name="test", + content="original content", + ) + original_digest = artifact.content_digest + original_updated_at = artifact.updated_at + + # Small delay to ensure timestamp difference + import time + time.sleep(0.01) + + new_content = "updated content" + artifact.update_content(new_content) + + assert artifact.content_digest != original_digest + assert artifact.content_digest == calculate_content_digest(new_content) + assert artifact.content_size == len(new_content.encode('utf-8')) + assert artifact.updated_at > original_updated_at + + def test_has_changed(self): + """Test change detection.""" + content = "original" + artifact = Artifact.create( + space_id="space-1", + name="test", + content=content, + ) + + # Same content - no change + assert not artifact.has_changed(calculate_content_digest(content)) + + # Different content - changed + assert artifact.has_changed(calculate_content_digest("different")) + + def test_artifact_to_dict(self): + """Test artifact serialization.""" + artifact = Artifact.create( + space_id="space-1", + name="test", + content="content", + artifact_type=ArtifactType.TEMPLATE, + ) + data = artifact.to_dict() + + assert data["id"] == artifact.id + assert data["space_id"] == "space-1" + assert data["name"] == "test" + assert data["artifact_type"] == "template" + assert data["content_digest"] == artifact.content_digest + assert data["content_size"] == artifact.content_size + assert "metadata" in data + assert "created_at" in data + assert "updated_at" in data + + def test_artifact_from_dict(self): + """Test artifact deserialization.""" + original = Artifact.create( + space_id="space-1", + name="test", + content="content", + ) + data = original.to_dict() + restored = Artifact.from_dict(data) + + assert restored.id == original.id + assert restored.space_id == original.space_id + assert restored.name == original.name + assert restored.artifact_type == original.artifact_type + assert restored.content_digest == original.content_digest + assert restored.content_size == original.content_size + + +class TestArtifactReference: + """Tests for ArtifactReference.""" + + def test_simple_reference(self): + """Test simple artifact name reference.""" + ref = ArtifactReference(name="my-artifact") + assert ref.name == "my-artifact" + assert ref.space_id is None + assert ref.version is None + assert str(ref) == "my-artifact" + + def test_reference_with_space(self): + """Test reference with space ID.""" + ref = ArtifactReference(name="artifact", space_id="space-1") + assert ref.name == "artifact" + assert ref.space_id == "space-1" + assert str(ref) == "space-1:artifact" + + def test_reference_with_version(self): + """Test reference with version.""" + ref = ArtifactReference(name="artifact", version="1.0.0") + assert ref.name == "artifact" + assert ref.version == "1.0.0" + assert str(ref) == "artifact@1.0.0" + + def test_reference_with_space_and_version(self): + """Test fully qualified reference.""" + ref = ArtifactReference( + name="artifact", + space_id="space-1", + version="2.0.0" + ) + assert str(ref) == "space-1:artifact@2.0.0" + + def test_parse_simple_reference(self): + """Test parsing simple reference.""" + ref = ArtifactReference.parse("my-artifact") + assert ref.name == "my-artifact" + assert ref.space_id is None + assert ref.version is None + + def test_parse_space_reference(self): + """Test parsing space:artifact reference.""" + ref = ArtifactReference.parse("space-1:my-artifact") + assert ref.name == "my-artifact" + assert ref.space_id == "space-1" + assert ref.version is None + + def test_parse_version_reference(self): + """Test parsing artifact@version reference.""" + ref = ArtifactReference.parse("my-artifact@1.0.0") + assert ref.name == "my-artifact" + assert ref.space_id is None + assert ref.version == "1.0.0" + + def test_parse_full_reference(self): + """Test parsing space:artifact@version reference.""" + ref = ArtifactReference.parse("space-1:my-artifact@2.0.0") + assert ref.name == "my-artifact" + assert ref.space_id == "space-1" + assert ref.version == "2.0.0" + + def test_parse_invalid_reference(self): + """Test parsing invalid reference raises error.""" + with pytest.raises(ValueError, match="Invalid artifact reference"): + ArtifactReference.parse("space-1:") + + with pytest.raises(ValueError, match="Invalid artifact reference"): + ArtifactReference.parse("@1.0.0") + + +class TestContentDigest: + """Tests for content digest functions.""" + + def test_calculate_content_digest(self): + """Test SHA-256 digest calculation.""" + content = "Hello, World!" + digest = calculate_content_digest(content) + # SHA-256 produces 64 hex characters + assert len(digest) == 64 + assert digest.isalnum() + + def test_digest_consistency(self): + """Test digest is consistent for same content.""" + content = "Test content" + digest1 = calculate_content_digest(content) + digest2 = calculate_content_digest(content) + assert digest1 == digest2 + + def test_digest_changes_with_content(self): + """Test digest changes when content changes.""" + digest1 = calculate_content_digest("content 1") + digest2 = calculate_content_digest("content 2") + assert digest1 != digest2 + + def test_calculate_bundle_digest(self): + """Test bundle digest calculation.""" + components = { + "template": "abc123", + "dep1": "def456", + "dep2": "ghi789", + } + digest = calculate_bundle_digest(components) + assert len(digest) == 64 + + def test_bundle_digest_order_independent(self): + """Test bundle digest is deterministic regardless of dict order.""" + components1 = {"a": "1", "b": "2", "c": "3"} + components2 = {"c": "3", "a": "1", "b": "2"} + assert calculate_bundle_digest(components1) == calculate_bundle_digest(components2) + + def test_bundle_digest_sensitive_to_values(self): + """Test bundle digest changes when values change.""" + digest1 = calculate_bundle_digest({"a": "1", "b": "2"}) + digest2 = calculate_bundle_digest({"a": "1", "b": "3"}) + assert digest1 != digest2 diff --git a/tests/unit/prompts/test_artifact_repository.py b/tests/unit/prompts/test_artifact_repository.py new file mode 100644 index 00000000..8dc824a6 --- /dev/null +++ b/tests/unit/prompts/test_artifact_repository.py @@ -0,0 +1,261 @@ +""" +Unit tests for artifact repository. + +Tests SQLiteArtifactRepository implementation. +""" + +import pytest +import tempfile +from pathlib import Path + +from markitect.prompts.models import Artifact, ArtifactType, ArtifactMetadata +from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository +from markitect.prompts.repositories.interfaces import ( + ArtifactNotFoundError, + DuplicateArtifactError, +) + + +@pytest.fixture +def temp_db(): + """Create temporary database for testing.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + yield db_path + # Cleanup + Path(db_path).unlink(missing_ok=True) + + +@pytest.fixture +def repository(temp_db): + """Create repository instance with temp database.""" + return SQLiteArtifactRepository(temp_db) + + +@pytest.fixture +def sample_artifact(): + """Create sample artifact for testing.""" + return Artifact.create( + space_id="test-space", + name="test-artifact", + content="# Test Content\n\nThis is test content.", + artifact_type=ArtifactType.CONTENT, + ) + + +class TestSQLiteArtifactRepository: + """Tests for SQLiteArtifactRepository.""" + + def test_create_artifact(self, repository, sample_artifact): + """Test creating an artifact.""" + created = repository.create(sample_artifact) + + assert created.id == sample_artifact.id + assert created.space_id == sample_artifact.space_id + assert created.name == sample_artifact.name + assert created.content_digest == sample_artifact.content_digest + + def test_create_duplicate_artifact_raises_error(self, repository, sample_artifact): + """Test creating duplicate artifact raises error.""" + repository.create(sample_artifact) + + # Try to create another artifact with same space_id and name + duplicate = Artifact.create( + space_id="test-space", + name="test-artifact", + content="Different content", + ) + + with pytest.raises(DuplicateArtifactError, match="already exists"): + repository.create(duplicate) + + def test_get_by_id(self, repository, sample_artifact): + """Test retrieving artifact by ID.""" + repository.create(sample_artifact) + + retrieved = repository.get_by_id(sample_artifact.id) + + assert retrieved is not None + assert retrieved.id == sample_artifact.id + assert retrieved.name == sample_artifact.name + assert retrieved.content_digest == sample_artifact.content_digest + + def test_get_by_id_not_found(self, repository): + """Test get_by_id returns None for non-existent ID.""" + result = repository.get_by_id("non-existent-id") + assert result is None + + def test_get_by_name(self, repository, sample_artifact): + """Test retrieving artifact by space and name.""" + repository.create(sample_artifact) + + retrieved = repository.get_by_name("test-space", "test-artifact") + + assert retrieved is not None + assert retrieved.id == sample_artifact.id + assert retrieved.space_id == "test-space" + assert retrieved.name == "test-artifact" + + def test_get_by_name_not_found(self, repository): + """Test get_by_name returns None when not found.""" + result = repository.get_by_name("test-space", "non-existent") + assert result is None + + def test_get_by_digest(self, repository): + """Test finding artifacts by content digest.""" + content = "Shared content" + artifact1 = Artifact.create( + space_id="space-1", + name="artifact-1", + content=content, + ) + artifact2 = Artifact.create( + space_id="space-2", + name="artifact-2", + content=content, # Same content, same digest + ) + + repository.create(artifact1) + repository.create(artifact2) + + results = repository.get_by_digest(artifact1.content_digest) + + assert len(results) == 2 + names = {a.name for a in results} + assert names == {"artifact-1", "artifact-2"} + + def test_list_by_space(self, repository): + """Test listing artifacts in a space.""" + artifacts = [ + Artifact.create( + space_id="space-1", + name=f"artifact-{i}", + content=f"Content {i}", + ) + for i in range(3) + ] + + for artifact in artifacts: + repository.create(artifact) + + # Add artifact in different space + other = Artifact.create( + space_id="space-2", + name="other", + content="Other content", + ) + repository.create(other) + + results = repository.list_by_space("space-1") + + assert len(results) == 3 + assert all(a.space_id == "space-1" for a in results) + # Should be ordered by name + assert [a.name for a in results] == ["artifact-0", "artifact-1", "artifact-2"] + + def test_list_by_space_with_type_filter(self, repository): + """Test listing artifacts filtered by type.""" + artifacts = [ + Artifact.create( + space_id="space-1", + name="content-1", + content="Content", + artifact_type=ArtifactType.CONTENT, + ), + Artifact.create( + space_id="space-1", + name="template-1", + content="Template", + artifact_type=ArtifactType.TEMPLATE, + ), + Artifact.create( + space_id="space-1", + name="content-2", + content="Content 2", + artifact_type=ArtifactType.CONTENT, + ), + ] + + for artifact in artifacts: + repository.create(artifact) + + results = repository.list_by_space("space-1", ArtifactType.CONTENT) + + assert len(results) == 2 + assert all(a.artifact_type == ArtifactType.CONTENT for a in results) + + def test_update_artifact(self, repository, sample_artifact): + """Test updating artifact.""" + repository.create(sample_artifact) + + # Update content + sample_artifact.update_content("New content") + updated = repository.update(sample_artifact) + + # Retrieve and verify + retrieved = repository.get_by_id(sample_artifact.id) + assert retrieved.content_digest == updated.content_digest + assert retrieved.updated_at >= updated.updated_at + + def test_update_nonexistent_artifact_raises_error(self, repository): + """Test updating non-existent artifact raises error.""" + artifact = Artifact.create( + space_id="test-space", + name="test", + content="content", + ) + + with pytest.raises(ArtifactNotFoundError, match="not found"): + repository.update(artifact) + + def test_delete_artifact(self, repository, sample_artifact): + """Test deleting artifact.""" + repository.create(sample_artifact) + + result = repository.delete(sample_artifact.id) + assert result is True + + # Verify deleted + retrieved = repository.get_by_id(sample_artifact.id) + assert retrieved is None + + def test_delete_nonexistent_artifact(self, repository): + """Test deleting non-existent artifact returns False.""" + result = repository.delete("non-existent-id") + assert result is False + + def test_exists(self, repository, sample_artifact): + """Test checking if artifact exists.""" + assert not repository.exists("test-space", "test-artifact") + + repository.create(sample_artifact) + + assert repository.exists("test-space", "test-artifact") + assert not repository.exists("test-space", "other-artifact") + assert not repository.exists("other-space", "test-artifact") + + def test_artifact_metadata_persistence(self, repository): + """Test metadata is persisted correctly.""" + metadata = ArtifactMetadata( + description="Test description", + tags=["tag1", "tag2"], + author="test-author", + version="1.0.0", + custom={"key": "value"}, + ) + + artifact = Artifact.create( + space_id="test-space", + name="test", + content="content", + metadata=metadata, + ) + + repository.create(artifact) + retrieved = repository.get_by_id(artifact.id) + + assert retrieved.metadata.description == "Test description" + assert retrieved.metadata.tags == ["tag1", "tag2"] + assert retrieved.metadata.author == "test-author" + assert retrieved.metadata.version == "1.0.0" + assert retrieved.metadata.custom == {"key": "value"}