feat(prompts): implement Phase 6 - Incremental Execution (FR-7, FR-8)
Add change detection, structural diff-based impact analysis, configurable-depth incremental recomputation with circular suppression, and impact debt tracking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
232
markitect/prompts/incremental/detector.py
Normal file
232
markitect/prompts/incremental/detector.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
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"]),
|
||||
)
|
||||
Reference in New Issue
Block a user