74fef908d2
Graduated heads can now apply their tag without a human — gated so it's safe:
- FIRING GATE: a head fires only when the master switch (head_auto_apply_enabled,
default OFF) is on AND it has >= head_auto_apply_min_positives (default 30)
clean labels. A precise-looking but under-supported low-N head can't spray tags.
- auto_apply_sweep (heads.py): streams every embedded image in chunks, scores
against the eligible heads (numpy, no sklearn), applies each head's tag where
score >= its auto_apply_threshold and the tag isn't already applied/rejected,
with source='head_auto' (distinguishable + reversible). dry_run counts only.
- HeadAutoApplyRun (migration 0059) tracks each sweep / preview; apply_head_tags
task (ml queue) + scheduled_apply_head_tags daily beat (no-op unless enabled)
+ recovery sweep + retention(20).
- API: POST /api/heads/auto-apply {dry_run} (202 / 409 running / 400 disabled),
GET /api/heads/auto-apply (recent runs + per-concept report). Settings
head_auto_apply_enabled + min_positives via /api/ml/settings.
Tests: sweep applies above threshold, dry-run writes nothing, skips under-
supported + ungraduated heads; API disabled/dry-run/conflict guards.
NEXT (slice 2): the observability the operator asked for — per-concept misfire
(auto-applied-then-removed) + under-fire tracking, time-series snapshots, and a
reporting API to tune. Slice 3: the UI (enable, preview, trends).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
47 lines
2.0 KiB
Python
47 lines
2.0 KiB
Python
"""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
|
|
)
|