48c8811d69
Auto-apply is now ON by default (operator-asked: opt-OUT, not opt-in) — migration 0059 + model default flipped. The support (>=30) + measured-precision gates keep it safe and every auto-tag is reversible. Observability so the operator can tune from real data: - MISFIRE = an auto-applied (source='head_auto') tag the operator later removes. UNDER-FIRE = a tag with a head the operator adds by hand (the head missed it). Both captured at correction time in TagService.add_to_image/remove_from_image (source is lost on delete) into durable per-tag counters (head_metric), keyed by tag so they survive head retrain/prune. - Daily snapshot_head_metrics writes a per-concept time-series point (head_metrics_snapshot): auto-applied volume + cumulative misfires/under-fires + head quality; 180-day retention; daily beat. - GET /api/heads/metrics: per-concept current counts + realized misfire rate + head quality, plus the snapshot time-series — the report to tune the precision target + support floor. Migration 0060. Tests: misfire/under-fire counting (and the negatives — manual removal isn't a misfire, headless manual add isn't an under-fire), snapshot time-series, metrics API. What's the autofire threshold? There's no single number — each graduated head derives its OWN probability cutoff from its PR curve: the operating point that holds precision >= head_auto_apply_precision (0.97) at max recall. The global knobs are that target + the >=30 support floor. NEXT (slice 3): UI — enable toggle, dry-run preview, per-concept trends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
71 lines
2.2 KiB
Python
71 lines
2.2 KiB
Python
"""head_auto_apply_run + earned-auto-apply settings (#114)
|
|
|
|
A graduated head can apply its tag without a human, gated by a master switch +
|
|
a support floor. head_auto_apply_run tracks each sweep / dry-run preview.
|
|
|
|
Revision ID: 0059
|
|
Revises: 0058
|
|
Create Date: 2026-06-29
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
import sqlalchemy as sa
|
|
from alembic import op
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
|
|
revision: str = "0059"
|
|
down_revision: Union[str, None] = "0058"
|
|
branch_labels: Union[str, Sequence[str], None] = None
|
|
depends_on: Union[str, Sequence[str], None] = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
"head_auto_apply_run",
|
|
sa.Column("id", sa.Integer(), primary_key=True),
|
|
sa.Column(
|
|
"dry_run", sa.Boolean(), nullable=False, server_default=sa.false()
|
|
),
|
|
sa.Column("params", JSONB(), nullable=False),
|
|
sa.Column(
|
|
"status", sa.String(length=16), nullable=False,
|
|
server_default="running",
|
|
),
|
|
sa.Column(
|
|
"started_at", sa.DateTime(timezone=True), nullable=False,
|
|
server_default=sa.func.now(),
|
|
),
|
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
|
sa.Column("n_applied", sa.Integer(), nullable=True),
|
|
sa.Column("report", JSONB(), nullable=True),
|
|
sa.Column("error", sa.Text(), nullable=True),
|
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
|
)
|
|
op.create_index(
|
|
"ix_head_auto_apply_run_status", "head_auto_apply_run", ["status"],
|
|
)
|
|
|
|
op.add_column(
|
|
"ml_settings",
|
|
sa.Column(
|
|
"head_auto_apply_enabled", sa.Boolean(), nullable=False,
|
|
server_default=sa.true(), # opt-out: on by default (operator-asked)
|
|
),
|
|
)
|
|
op.add_column(
|
|
"ml_settings",
|
|
sa.Column(
|
|
"head_auto_apply_min_positives", sa.Integer(), nullable=False,
|
|
server_default="30",
|
|
),
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_column("ml_settings", "head_auto_apply_min_positives")
|
|
op.drop_column("ml_settings", "head_auto_apply_enabled")
|
|
op.drop_index(
|
|
"ix_head_auto_apply_run_status", table_name="head_auto_apply_run"
|
|
)
|
|
op.drop_table("head_auto_apply_run")
|