Files
markitect-main/markitect/prompts/models.py
tegwick 945544880d 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 <noreply@anthropic.com>
2026-02-08 22:30:26 +01:00

280 lines
8.3 KiB
Python

"""
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()