"""Deprecation tracker for legacy pipeline components. Manages the lifecycle of deprecated components: VLLMClient, v2 prompts, provider branching, 8000-char truncation, environment/model defaults, provider free-text fields, and the compatibility adapter. Removal only happens after all downstream consumers read v3 natively, validated by consumer audit. """ from __future__ import annotations import enum from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from uuid import UUID, uuid4 class DeprecationStatus(str, enum.Enum): """Lifecycle status of a deprecated component.""" ACTIVE = "active" # Still in use DEPRECATED = "deprecated" # Marked for removal, consumers migrating MIGRATION_COMPLETE = "migration_complete" # All consumers migrated REMOVED = "removed" # Code removed ARCHIVED = "archived" # Final reports preserved @dataclass class DeprecationEntry: """A tracked deprecated component with migration status.""" entry_id: UUID component_name: str component_path: str # File/module path status: DeprecationStatus deprecated_at: datetime reason: str # Consumer tracking known_consumers: list[str] = field(default_factory=list) migrated_consumers: list[str] = field(default_factory=list) # Removal gates removal_approved: bool = False removal_approver: str = "" removed_at: datetime | None = None # Migration tracking replacement: str = "" # What replaces this component migration_notes: str = "" @classmethod def create( cls, component_name: str, component_path: str, reason: str, known_consumers: list[str] | None = None, replacement: str = "", ) -> DeprecationEntry: return cls( entry_id=uuid4(), component_name=component_name, component_path=component_path, status=DeprecationStatus.DEPRECATED, deprecated_at=datetime.now(timezone.utc), reason=reason, known_consumers=known_consumers or [], replacement=replacement, ) @property def migration_progress(self) -> float: """Fraction of consumers that have migrated (0.0-1.0).""" if not self.known_consumers: return 1.0 return len(self.migrated_consumers) / len(self.known_consumers) @property def all_consumers_migrated(self) -> bool: """Whether all known consumers have migrated.""" return set(self.known_consumers) <= set(self.migrated_consumers) def mark_consumer_migrated(self, consumer: str) -> None: """Record that a consumer has migrated off this component.""" if consumer not in self.migrated_consumers: self.migrated_consumers.append(consumer) if self.all_consumers_migrated: self.status = DeprecationStatus.MIGRATION_COMPLETE def approve_removal(self, approver: str) -> bool: """Approve removal. Only valid if all consumers migrated. Returns False if removal cannot be approved. """ if not self.all_consumers_migrated: return False self.removal_approved = True self.removal_approver = approver return True def mark_removed(self) -> None: """Record that the component has been removed from code.""" self.status = DeprecationStatus.REMOVED self.removed_at = datetime.now(timezone.utc) def archive(self) -> None: """Archive after final migration reports preserved.""" self.status = DeprecationStatus.ARCHIVED # Default deprecation entries for the v3 migration DEFAULT_DEPRECATIONS: list[dict[str, Any]] = [ { "component_name": "VLLMClient", "component_path": "services/extractor/vllm_client.py", "reason": "Replaced by OpenAICompatibleClient via inference gateway", "known_consumers": [ "services/extractor/llm_factory.py", "services/extractor/worker.py", ], "replacement": "services/shared/inference/clients/openai_compatible.py", }, { "component_name": "v2_extraction_prompt", "component_path": "services/extractor/prompts.py", "reason": "Monolithic prompt replaced by staged specialist extraction", "known_consumers": [ "services/extractor/worker.py", ], "replacement": "services/intelligence_pipeline_v3/adjudication/", }, { "component_name": "provider_branching", "component_path": "services/extractor/llm_factory.py", "reason": "Duplicated if/else provider branching replaced by registry", "known_consumers": [ "services/extractor/worker.py", "services/recommendation/thesis_llm.py", ], "replacement": "services/shared/inference/registry.py", }, { "component_name": "8000_char_truncation", "component_path": "services/extractor/prompts.py", "reason": "Truncation replaced by sentence-aware segmenter", "known_consumers": [ "services/extractor/prompts.py", ], "replacement": "services/intelligence_pipeline_v3/segmenter/", }, { "component_name": "compatibility_adapter", "component_path": "services/intelligence_pipeline_v3/compatibility/", "reason": "Temporary adapter removed after all consumers read v3 natively", "known_consumers": [ "services/aggregation/worker.py", "services/recommendation/", "services/query_api/", ], "replacement": "Direct v3 intelligence records", }, ] @dataclass class MigrationReport: """Summary report of the deprecation/migration status.""" generated_at: datetime total_components: int = 0 deprecated: int = 0 migration_complete: int = 0 removed: int = 0 blocked_removals: list[str] = field(default_factory=list) @classmethod def generate(cls, entries: list[DeprecationEntry]) -> MigrationReport: report = cls( generated_at=datetime.now(timezone.utc), total_components=len(entries), ) for entry in entries: if entry.status == DeprecationStatus.DEPRECATED: report.deprecated += 1 if not entry.all_consumers_migrated: remaining = set(entry.known_consumers) - set( entry.migrated_consumers ) report.blocked_removals.append( f"{entry.component_name}: waiting on {list(remaining)}" ) elif entry.status == DeprecationStatus.MIGRATION_COMPLETE: report.migration_complete += 1 elif entry.status in ( DeprecationStatus.REMOVED, DeprecationStatus.ARCHIVED, ): report.removed += 1 return report def to_dict(self) -> dict[str, Any]: return { "generated_at": self.generated_at.isoformat(), "total_components": self.total_components, "deprecated": self.deprecated, "migration_complete": self.migration_complete, "removed": self.removed, "blocked_removals": self.blocked_removals, } @dataclass class DeprecationTracker: """Tracks all deprecated components and their migration status. Enforces that removal only happens after all consumers migrate and with explicit approval. """ _entries: dict[str, DeprecationEntry] = field(default_factory=dict) def add(self, entry: DeprecationEntry) -> None: """Register a deprecated component.""" self._entries[entry.component_name] = entry def get(self, component_name: str) -> DeprecationEntry | None: return self._entries.get(component_name) def mark_migrated(self, component_name: str, consumer: str) -> bool: """Record a consumer migration. Returns False if component not found.""" entry = self._entries.get(component_name) if entry is None: return False entry.mark_consumer_migrated(consumer) return True def can_remove(self, component_name: str) -> bool: """Check if a component can be safely removed.""" entry = self._entries.get(component_name) if entry is None: return False return entry.all_consumers_migrated and entry.removal_approved def approve_removal(self, component_name: str, approver: str) -> bool: """Approve removal of a component.""" entry = self._entries.get(component_name) if entry is None: return False return entry.approve_removal(approver) def generate_report(self) -> MigrationReport: """Generate a migration status report.""" return MigrationReport.generate(list(self._entries.values())) @property def all_entries(self) -> list[DeprecationEntry]: return list(self._entries.values()) @property def pending_removals(self) -> list[DeprecationEntry]: """Entries that are ready for removal (migrated + approved).""" return [ e for e in self._entries.values() if e.all_consumers_migrated and e.removal_approved and e.status != DeprecationStatus.REMOVED ]