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
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Heads API (#114): train + inspect the per-concept heads that power
|
|
suggestions (replacing Camie + centroid).
|
|
|
|
POST /api/heads/train — (re)train all eligible heads (one run at a time).
|
|
GET /api/heads — status: head count, last-trained, running run, the
|
|
per-concept head table (strength + auto-apply ready),
|
|
and recent training runs. The card rehydrates from
|
|
here so status survives navigation.
|
|
"""
|
|
|
|
from quart import Blueprint, jsonify, request
|
|
from sqlalchemy import desc, func, select
|
|
|
|
from ..extensions import get_session
|
|
from ..models import (
|
|
HeadAutoApplyRun,
|
|
HeadMetric,
|
|
HeadMetricsSnapshot,
|
|
HeadTrainingRun,
|
|
Tag,
|
|
TagHead,
|
|
)
|
|
from ..models.tag import image_tag
|
|
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")
|
|
|
|
|
|
def _serialize_run(run: HeadTrainingRun) -> dict:
|
|
return {
|
|
"id": run.id,
|
|
"params": run.params,
|
|
"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_trained": run.n_trained,
|
|
"n_skipped": run.n_skipped,
|
|
"error": run.error,
|
|
}
|
|
|
|
|
|
@heads_bp.route("/train", methods=["POST"])
|
|
async def train():
|
|
body = await request.get_json(silent=True) or {}
|
|
params = body.get("params") or body or {}
|
|
async with get_session() as session:
|
|
try:
|
|
run_id = await session.run_sync(
|
|
lambda s: start_head_training_run(s, params)
|
|
)
|
|
except HeadTrainingAlreadyRunning as running:
|
|
return jsonify({
|
|
"error": "training_already_running",
|
|
"running_id": int(running.args[0]),
|
|
}), 409
|
|
await session.commit()
|
|
return jsonify({"run_id": run_id, "status": "running"}), 202
|
|
|
|
|
|
@heads_bp.route("", methods=["GET"])
|
|
async def status():
|
|
async with get_session() as session:
|
|
count, last_trained = (
|
|
await session.execute(
|
|
select(func.count(), func.max(TagHead.trained_at))
|
|
)
|
|
).one()
|
|
graduated = (
|
|
await session.execute(
|
|
select(func.count()).where(
|
|
TagHead.auto_apply_threshold.is_not(None)
|
|
)
|
|
)
|
|
).scalar_one()
|
|
running = (
|
|
await session.execute(
|
|
select(HeadTrainingRun.id)
|
|
.where(HeadTrainingRun.status == "running")
|
|
.order_by(HeadTrainingRun.id.desc())
|
|
.limit(1)
|
|
)
|
|
).scalar_one_or_none()
|
|
runs = (
|
|
await session.execute(
|
|
select(HeadTrainingRun)
|
|
.order_by(HeadTrainingRun.id.desc())
|
|
.limit(10)
|
|
)
|
|
).scalars().all()
|
|
# The per-concept table: strongest first, capped for the admin card.
|
|
head_rows = (
|
|
await session.execute(
|
|
select(
|
|
TagHead.tag_id, Tag.name, Tag.kind,
|
|
TagHead.n_pos, TagHead.n_neg, TagHead.ap,
|
|
TagHead.precision_cv, TagHead.recall,
|
|
TagHead.auto_apply_threshold, TagHead.trained_at,
|
|
)
|
|
.join(Tag, Tag.id == TagHead.tag_id)
|
|
.order_by(desc(TagHead.ap))
|
|
.limit(500)
|
|
)
|
|
).all()
|
|
heads = [
|
|
{
|
|
"tag_id": r.tag_id,
|
|
"name": r.name,
|
|
"category": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
|
|
"n_pos": r.n_pos,
|
|
"n_neg": r.n_neg,
|
|
"ap": r.ap,
|
|
"precision": r.precision_cv,
|
|
"recall": r.recall,
|
|
"auto_apply": r.auto_apply_threshold is not None,
|
|
"trained_at": r.trained_at.isoformat() if r.trained_at else None,
|
|
}
|
|
for r in head_rows
|
|
]
|
|
return jsonify({
|
|
"head_count": count,
|
|
"graduated_count": graduated,
|
|
"last_trained_at": last_trained.isoformat() if last_trained else None,
|
|
"running_id": running,
|
|
"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],
|
|
})
|
|
|
|
|
|
@heads_bp.route("/metrics", methods=["GET"])
|
|
async def metrics():
|
|
"""Auto-apply observability: per-concept current counts (volume, misfires,
|
|
under-fires, realized misfire rate, head quality) + the daily time-series so
|
|
the operator can tune the precision target + support floor from real data."""
|
|
async with get_session() as session:
|
|
head_rows = (
|
|
await session.execute(
|
|
select(
|
|
TagHead.tag_id, Tag.name, TagHead.ap, TagHead.precision_cv,
|
|
TagHead.recall, TagHead.auto_apply_threshold, TagHead.n_pos,
|
|
).join(Tag, Tag.id == TagHead.tag_id)
|
|
)
|
|
).all()
|
|
heads = {r.tag_id: r for r in head_rows}
|
|
metric_rows = (
|
|
await session.execute(
|
|
select(
|
|
HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires
|
|
)
|
|
)
|
|
).all()
|
|
mets = {r.tag_id: r for r in metric_rows}
|
|
applied = dict(
|
|
(
|
|
await session.execute(
|
|
select(image_tag.c.tag_id, func.count())
|
|
.where(image_tag.c.source == "head_auto")
|
|
.group_by(image_tag.c.tag_id)
|
|
)
|
|
).all()
|
|
)
|
|
names = {r.tag_id: r.name for r in head_rows}
|
|
# Names for metric-only tags (head pruned but corrections recorded).
|
|
missing = [t for t in mets if t not in names]
|
|
if missing:
|
|
for tid, nm in (
|
|
await session.execute(
|
|
select(Tag.id, Tag.name).where(Tag.id.in_(missing))
|
|
)
|
|
).all():
|
|
names[tid] = nm
|
|
|
|
concepts = []
|
|
for tid in set(heads) | set(mets):
|
|
h = heads.get(tid)
|
|
m = mets.get(tid)
|
|
n_applied = applied.get(tid, 0)
|
|
n_mis = m.n_misfires if m else 0
|
|
denom = n_applied + n_mis
|
|
concepts.append({
|
|
"tag_id": tid,
|
|
"name": names.get(tid, str(tid)),
|
|
"n_auto_applied": n_applied,
|
|
"n_misfires": n_mis,
|
|
"n_underfires": m.n_underfires if m else 0,
|
|
# Of everything this head ever auto-applied, the fraction you
|
|
# removed — the misfire rate (null until something fired).
|
|
"misfire_rate": round(n_mis / denom, 4) if denom else None,
|
|
"ap": h.ap if h else None,
|
|
"precision_cv": h.precision_cv if h else None,
|
|
"recall": h.recall if h else None,
|
|
"auto_apply": bool(h and h.auto_apply_threshold is not None),
|
|
"n_pos": h.n_pos if h else None,
|
|
})
|
|
concepts.sort(key=lambda c: (c["n_misfires"], c["n_auto_applied"]), reverse=True)
|
|
|
|
snaps = (
|
|
await session.execute(
|
|
select(HeadMetricsSnapshot)
|
|
.order_by(HeadMetricsSnapshot.snapshot_at.desc())
|
|
.limit(1000)
|
|
)
|
|
).scalars().all()
|
|
return jsonify({
|
|
"concepts": concepts,
|
|
"snapshots": [
|
|
{
|
|
"tag_id": s.tag_id,
|
|
"name": s.name,
|
|
"snapshot_at": s.snapshot_at.isoformat() if s.snapshot_at else None,
|
|
"n_auto_applied": s.n_auto_applied,
|
|
"n_misfires": s.n_misfires,
|
|
"n_underfires": s.n_underfires,
|
|
"ap": s.ap,
|
|
"precision_cv": s.precision_cv,
|
|
"recall": s.recall,
|
|
"n_pos": s.n_pos,
|
|
}
|
|
for s in snaps
|
|
],
|
|
})
|