feat(heads): earned auto-apply — sweep mechanism, off by default (#114 auto-apply A)
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
This commit is contained in:
@@ -12,8 +12,14 @@ from quart import Blueprint, jsonify, request
|
||||
from sqlalchemy import desc, func, select
|
||||
|
||||
from ..extensions import get_session
|
||||
from ..models import HeadTrainingRun, Tag, TagHead
|
||||
from ..services.ml.heads import HeadTrainingAlreadyRunning, start_head_training_run
|
||||
from ..models import HeadAutoApplyRun, HeadTrainingRun, Tag, TagHead
|
||||
from ..services.ml.heads import (
|
||||
HeadAutoApplyAlreadyRunning,
|
||||
HeadAutoApplyDisabled,
|
||||
HeadTrainingAlreadyRunning,
|
||||
start_head_auto_apply_run,
|
||||
start_head_training_run,
|
||||
)
|
||||
|
||||
heads_bp = Blueprint("heads", __name__, url_prefix="/api/heads")
|
||||
|
||||
@@ -116,3 +122,62 @@ async def status():
|
||||
"runs": [_serialize_run(r) for r in runs],
|
||||
"heads": heads,
|
||||
})
|
||||
|
||||
|
||||
def _serialize_apply_run(run: HeadAutoApplyRun) -> dict:
|
||||
return {
|
||||
"id": run.id,
|
||||
"dry_run": run.dry_run,
|
||||
"status": run.status,
|
||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||
"n_applied": run.n_applied,
|
||||
"report": run.report,
|
||||
"error": run.error,
|
||||
}
|
||||
|
||||
|
||||
@heads_bp.route("/auto-apply", methods=["POST"])
|
||||
async def auto_apply():
|
||||
"""Trigger an earned-auto-apply sweep. {dry_run:true} previews (writes
|
||||
nothing); a real sweep needs head_auto_apply_enabled on."""
|
||||
body = await request.get_json(silent=True) or {}
|
||||
params = {"dry_run": bool(body.get("dry_run", False))}
|
||||
async with get_session() as session:
|
||||
try:
|
||||
run_id = await session.run_sync(
|
||||
lambda s: start_head_auto_apply_run(s, params)
|
||||
)
|
||||
except HeadAutoApplyAlreadyRunning as running:
|
||||
return jsonify({
|
||||
"error": "auto_apply_already_running",
|
||||
"running_id": int(running.args[0]),
|
||||
}), 409
|
||||
except HeadAutoApplyDisabled:
|
||||
return jsonify({"error": "auto_apply_disabled"}), 400
|
||||
await session.commit()
|
||||
return jsonify({"run_id": run_id, "status": "running"}), 202
|
||||
|
||||
|
||||
@heads_bp.route("/auto-apply", methods=["GET"])
|
||||
async def auto_apply_status():
|
||||
async with get_session() as session:
|
||||
running = (
|
||||
await session.execute(
|
||||
select(HeadAutoApplyRun.id)
|
||||
.where(HeadAutoApplyRun.status == "running")
|
||||
.order_by(HeadAutoApplyRun.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
runs = (
|
||||
await session.execute(
|
||||
select(HeadAutoApplyRun)
|
||||
.order_by(HeadAutoApplyRun.id.desc())
|
||||
.limit(10)
|
||||
)
|
||||
).scalars().all()
|
||||
return jsonify({
|
||||
"running_id": running,
|
||||
"runs": [_serialize_apply_run(r) for r in runs],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user