"""HeadAutoApplyRun — persisted lifecycle of an earned-auto-apply sweep (#114). A graduated head can apply its tag to images it scores above the head's auto-apply threshold, without a human. This row tracks one such sweep (or a dry-run PREVIEW of it) so the result survives navigation and the admin card can show what fired / what would fire. Mirrors HeadTrainingRun. State machine: running → ready / error. The `report` JSONB holds per-concept counts (applied / projected / scanned). """ from datetime import datetime from typing import Any from sqlalchemy import Boolean, DateTime, Integer, String, Text, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from .base import Base class HeadAutoApplyRun(Base): __tablename__ = "head_auto_apply_run" id: Mapped[int] = mapped_column(Integer, primary_key=True) # dry_run=True is a PREVIEW: scores + counts what WOULD apply, writes nothing # (preview/apply parity, rule 93). dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) status: Mapped[str] = mapped_column( String(16), nullable=False, default="running", index=True ) # running | ready | error started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) finished_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Total tags applied across all heads this sweep (0 for a clean dry-run). n_applied: Mapped[int | None] = mapped_column(Integer, nullable=True) # Per-concept breakdown: [{tag_id, name, applied, scanned, threshold}, ...]. report: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True) error: Mapped[str | None] = mapped_column(Text, nullable=True) last_progress_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True )