""" Change detector for artifact content changes. Implements FR-7.1: Change detection with persistent records. Detects changes by comparing content digests and persists ArtifactChange records to the artifact_changes table. """ import sqlite3 from pathlib import Path from typing import List, Optional from markitect.prompts.models import Artifact, calculate_content_digest from markitect.prompts.incremental.models import ArtifactChange, ChangeType # SQL schema for artifact_changes table CHANGES_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS artifact_changes ( id TEXT PRIMARY KEY, artifact_id TEXT NOT NULL, old_digest TEXT, new_digest TEXT NOT NULL, change_type TEXT NOT NULL, detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_changes_artifact ON artifact_changes(artifact_id); CREATE INDEX IF NOT EXISTS idx_changes_type ON artifact_changes(change_type); """ class ChangeDetector: """ Detects and records artifact content changes. Uses digest comparison to detect changes and persists ArtifactChange records to SQLite for auditing and incremental recomputation triggering. """ def __init__(self, db_path: str): """ Initialize with database path and ensure tables exist. Args: db_path: Path to SQLite database file """ self.db_path = db_path self._initialize_tables() def _initialize_tables(self) -> None: """Initialize artifact_changes table if not exists.""" db_dir = Path(self.db_path).parent if db_dir and not db_dir.exists(): db_dir.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(self.db_path) try: conn.executescript(CHANGES_TABLE_SQL) conn.commit() finally: conn.close() def _get_connection(self) -> sqlite3.Connection: """Get a database connection.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def detect_change( self, artifact: Artifact, new_content: str, ) -> Optional[ArtifactChange]: """ Detect if artifact content has changed. Compares the artifact's stored digest against the new content digest. If changed, creates and returns an ArtifactChange record (not yet persisted). Args: artifact: Existing artifact to check new_content: New content to compare against Returns: ArtifactChange if content changed, None otherwise """ new_digest = calculate_content_digest(new_content) if not artifact.has_changed(new_digest): return None return ArtifactChange.create( artifact_id=artifact.id, old_digest=artifact.content_digest, new_digest=new_digest, change_type=ChangeType.MODIFIED, ) def detect_creation( self, artifact_id: str, content: str, ) -> ArtifactChange: """ Record creation of a new artifact. Args: artifact_id: ID of newly created artifact content: Content of new artifact Returns: ArtifactChange record for the creation """ new_digest = calculate_content_digest(content) return ArtifactChange.create( artifact_id=artifact_id, old_digest=None, new_digest=new_digest, change_type=ChangeType.CREATED, ) def detect_deletion( self, artifact: Artifact, ) -> ArtifactChange: """ Record deletion of an artifact. Args: artifact: Artifact being deleted Returns: ArtifactChange record for the deletion """ return ArtifactChange.create( artifact_id=artifact.id, old_digest=artifact.content_digest, new_digest=artifact.content_digest, change_type=ChangeType.DELETED, ) def record_change(self, change: ArtifactChange) -> ArtifactChange: """ Persist an ArtifactChange record to the database. Args: change: Change record to persist Returns: Persisted change record """ conn = self._get_connection() try: conn.execute( """ INSERT INTO artifact_changes ( id, artifact_id, old_digest, new_digest, change_type, detected_at ) VALUES (?, ?, ?, ?, ?, ?) """, ( change.id, change.artifact_id, change.old_digest, change.new_digest, change.change_type.value, change.detected_at.isoformat(), ), ) conn.commit() return change finally: conn.close() def get_changes_for_artifact(self, artifact_id: str) -> List[ArtifactChange]: """ Get all recorded changes for an artifact. Args: artifact_id: Artifact identifier Returns: List of change records, ordered by detection time """ conn = self._get_connection() try: cursor = conn.execute( """ SELECT * FROM artifact_changes WHERE artifact_id = ? ORDER BY detected_at """, (artifact_id,), ) return [self._row_to_change(row) for row in cursor.fetchall()] finally: conn.close() def get_changes_by_type(self, change_type: ChangeType) -> List[ArtifactChange]: """ Get all recorded changes of a specific type. Args: change_type: Type of change to filter by Returns: List of change records """ conn = self._get_connection() try: cursor = conn.execute( "SELECT * FROM artifact_changes WHERE change_type = ?", (change_type.value,), ) return [self._row_to_change(row) for row in cursor.fetchall()] finally: conn.close() def _row_to_change(self, row: sqlite3.Row) -> ArtifactChange: """Convert database row to ArtifactChange instance.""" from datetime import datetime return ArtifactChange( id=row["id"], artifact_id=row["artifact_id"], old_digest=row["old_digest"], new_digest=row["new_digest"], change_type=ChangeType(row["change_type"]), detected_at=datetime.fromisoformat(row["detected_at"]), )