From 48c8811d690c1a6cbfc85d678f09b5e6c4d5d23f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 29 Jun 2026 00:36:58 -0400 Subject: [PATCH] feat(heads): auto-apply observability + on by default (#114 auto-apply B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa --- alembic/versions/0059_head_auto_apply.py | 2 +- alembic/versions/0060_head_metrics.py | 74 ++++++++++++++ backend/app/api/heads.py | 104 ++++++++++++++++++- backend/app/celery_app.py | 4 + backend/app/models/__init__.py | 4 + backend/app/models/head_metric.py | 32 ++++++ backend/app/models/head_metrics_snapshot.py | 38 +++++++ backend/app/models/ml_settings.py | 9 +- backend/app/services/tag_service.py | 53 +++++++++- backend/app/tasks/maintenance.py | 73 +++++++++++++ tests/test_head_metrics.py | 107 ++++++++++++++++++++ 11 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/0060_head_metrics.py create mode 100644 backend/app/models/head_metric.py create mode 100644 backend/app/models/head_metrics_snapshot.py create mode 100644 tests/test_head_metrics.py diff --git a/alembic/versions/0059_head_auto_apply.py b/alembic/versions/0059_head_auto_apply.py index fc9afaf..d0bb9b8 100644 --- a/alembic/versions/0059_head_auto_apply.py +++ b/alembic/versions/0059_head_auto_apply.py @@ -49,7 +49,7 @@ def upgrade() -> None: "ml_settings", sa.Column( "head_auto_apply_enabled", sa.Boolean(), nullable=False, - server_default=sa.false(), + server_default=sa.true(), # opt-out: on by default (operator-asked) ), ) op.add_column( diff --git a/alembic/versions/0060_head_metrics.py b/alembic/versions/0060_head_metrics.py new file mode 100644 index 0000000..e94edb8 --- /dev/null +++ b/alembic/versions/0060_head_metrics.py @@ -0,0 +1,74 @@ +"""head_metric + head_metrics_snapshot: auto-apply observability (#114) + +Running misfire/under-fire counters per concept (captured at correction time, +since image_tag.source is lost on delete) + a daily per-concept time-series so +the operator can tune the precision target + support floor from real data. + +Revision ID: 0060 +Revises: 0059 +Create Date: 2026-06-29 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0060" +down_revision: Union[str, None] = "0059" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "head_metric", + sa.Column( + "tag_id", sa.Integer(), + sa.ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, + ), + sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"), + sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now(), + ), + ) + + op.create_table( + "head_metrics_snapshot", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "tag_id", sa.Integer(), + sa.ForeignKey("tag.id", ondelete="CASCADE"), + ), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column( + "snapshot_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now(), + ), + sa.Column("n_auto_applied", sa.Integer(), nullable=False, server_default="0"), + sa.Column("n_misfires", sa.Integer(), nullable=False, server_default="0"), + sa.Column("n_underfires", sa.Integer(), nullable=False, server_default="0"), + sa.Column("ap", sa.Float(), nullable=True), + sa.Column("precision_cv", sa.Float(), nullable=True), + sa.Column("recall", sa.Float(), nullable=True), + sa.Column("n_pos", sa.Integer(), nullable=True), + ) + op.create_index( + "ix_head_metrics_snapshot_tag_id", "head_metrics_snapshot", ["tag_id"], + ) + op.create_index( + "ix_head_metrics_snapshot_snapshot_at", "head_metrics_snapshot", + ["snapshot_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_head_metrics_snapshot_snapshot_at", table_name="head_metrics_snapshot" + ) + op.drop_index( + "ix_head_metrics_snapshot_tag_id", table_name="head_metrics_snapshot" + ) + op.drop_table("head_metrics_snapshot") + op.drop_table("head_metric") diff --git a/backend/app/api/heads.py b/backend/app/api/heads.py index 5b6243f..e6f10eb 100644 --- a/backend/app/api/heads.py +++ b/backend/app/api/heads.py @@ -12,7 +12,15 @@ from quart import Blueprint, jsonify, request from sqlalchemy import desc, func, select from ..extensions import get_session -from ..models import HeadAutoApplyRun, HeadTrainingRun, Tag, TagHead +from ..models import ( + HeadAutoApplyRun, + HeadMetric, + HeadMetricsSnapshot, + HeadTrainingRun, + Tag, + TagHead, +) +from ..models.tag import image_tag from ..services.ml.heads import ( HeadAutoApplyAlreadyRunning, HeadAutoApplyDisabled, @@ -181,3 +189,97 @@ async def auto_apply_status(): "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 + ], + }) diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index 80e77a3..9617d6c 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -117,6 +117,10 @@ def make_celery() -> Celery: "task": "backend.app.tasks.ml.scheduled_apply_head_tags", "schedule": 86400.0, # no-op unless head_auto_apply_enabled }, + "snapshot-head-metrics-daily": { + "task": "backend.app.tasks.maintenance.snapshot_head_metrics", + "schedule": 86400.0, + }, "integrity-verify-weekly": { "task": "backend.app.tasks.maintenance.verify_integrity", "schedule": 604800.0, # weekly diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e688bf0..681adb7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from .credential import Credential from .download_event import DownloadEvent from .external_link import ExternalLink from .head_auto_apply_run import HeadAutoApplyRun +from .head_metric import HeadMetric +from .head_metrics_snapshot import HeadMetricsSnapshot from .head_training_run import HeadTrainingRun from .image_prediction import ImagePrediction from .image_provenance import ImageProvenance @@ -69,6 +71,8 @@ __all__ = [ "LibraryAuditRun", "MLSettings", "HeadAutoApplyRun", + "HeadMetric", + "HeadMetricsSnapshot", "HeadTrainingRun", "TagAlias", "TagAllowlist", diff --git a/backend/app/models/head_metric.py b/backend/app/models/head_metric.py new file mode 100644 index 0000000..a034e51 --- /dev/null +++ b/backend/app/models/head_metric.py @@ -0,0 +1,32 @@ +"""HeadMetric — running correction counters per concept (#114 observability). + +Earned auto-apply fires graduated heads; to TUNE it we need to know how often a +head's auto-applied tag was wrong (the operator removed it = a MISFIRE) and how +often the operator had to add a tag a head exists for by hand (an UNDER-FIRE, +the head missed it). image_tag.source is lost when a row is deleted, so these +are captured as durable cumulative counters at correction time — they survive +head retrain/prune (keyed by tag, not by the head row). The daily snapshot reads +them into the time-series. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class HeadMetric(Base): + __tablename__ = "head_metric" + + tag_id: Mapped[int] = mapped_column( + ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True + ) + # An auto-applied (source='head_auto') tag the operator later REMOVED. + n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # A tag with a head that the operator added by HAND (the head missed it). + n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) diff --git a/backend/app/models/head_metrics_snapshot.py b/backend/app/models/head_metrics_snapshot.py new file mode 100644 index 0000000..a9ec7ac --- /dev/null +++ b/backend/app/models/head_metrics_snapshot.py @@ -0,0 +1,38 @@ +"""HeadMetricsSnapshot — a daily per-concept time-series point (#114). + +The "amount of change over time" reporting the operator asked for: once a day, +record each concept's auto-applied VOLUME (current head_auto tags), cumulative +misfires/under-fires, and the head's measured quality. Plotting these rows over +time shows whether auto-apply is landing better/worse and whether tagging more is +sharpening a concept — the signal for tuning the precision target + support floor. +""" + +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from .base import Base + + +class HeadMetricsSnapshot(Base): + __tablename__ = "head_metrics_snapshot" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + tag_id: Mapped[int] = mapped_column( + ForeignKey("tag.id", ondelete="CASCADE"), index=True + ) + # Denormalized so a snapshot stays readable even if the tag is later renamed. + name: Mapped[str] = mapped_column(String(255), nullable=False) + snapshot_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), index=True + ) + # Current count of source='head_auto' applications still standing. + n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # The head's measured quality at snapshot time (null if no head exists). + ap: Mapped[float | None] = mapped_column(Float, nullable=True) + precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True) + recall: Mapped[float | None] = mapped_column(Float, nullable=True) + n_pos: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/ml_settings.py b/backend/app/models/ml_settings.py index ea815f2..696787b 100644 --- a/backend/app/models/ml_settings.py +++ b/backend/app/models/ml_settings.py @@ -75,12 +75,13 @@ class MLSettings(Base): Float, nullable=False, default=0.97 ) # Earned auto-apply (#114). A graduated head fires (tags images without a - # human) ONLY when this master switch is on AND the head has at least + # human) when this master switch is on AND the head has at least # head_auto_apply_min_positives clean labels — so a precise-looking but - # under-supported low-N head can't spray tags across the library. Off by - # default; the operator enables after previewing. Operator-tunable. + # under-supported low-N head can't spray tags across the library. ON by + # default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support + + # measured-precision gates keep it safe, and every auto-tag is reversible. head_auto_apply_enabled: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False + Boolean, nullable=False, default=True ) head_auto_apply_min_positives: Mapped[int] = mapped_column( Integer, nullable=False, default=30 diff --git a/backend/app/services/tag_service.py b/backend/app/services/tag_service.py index 495875a..839071c 100644 --- a/backend/app/services/tag_service.py +++ b/backend/app/services/tag_service.py @@ -9,7 +9,7 @@ from sqlalchemy import and_, case, exists, func, select, text, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession -from ..models import Tag, TagKind, image_tag +from ..models import HeadMetric, Tag, TagHead, TagKind, image_tag from ..models.tag_allowlist import TagAllowlist from ..models.tag_reference_embedding import TagReferenceEmbedding from .db_helpers import get_or_create @@ -215,6 +215,18 @@ class TagService: async def add_to_image(self, image_id: int, tag_id: int, source: str = "manual") -> None: """Idempotent: re-adding an existing tag does nothing.""" + # A genuinely-new MANUAL add of a tag that already has a head is an + # UNDER-FIRE signal — the auto-system should have caught it (#114 obs). + is_new = source == "manual" and ( + await self.session.execute( + select(image_tag.c.tag_id).where( + and_( + image_tag.c.image_record_id == image_id, + image_tag.c.tag_id == tag_id, + ) + ) + ) + ).first() is None stmt = pg_insert(image_tag).values( image_record_id=image_id, tag_id=tag_id, source=source ) @@ -222,8 +234,22 @@ class TagService: index_elements=["image_record_id", "tag_id"] ) await self.session.execute(stmt) + if is_new: + await self._note_under_fire(tag_id) async def remove_from_image(self, image_id: int, tag_id: int) -> None: + # Removing an auto-applied (source='head_auto') tag is a MISFIRE — read + # the source BEFORE deleting, since it's lost with the row (#114 obs). + src = ( + await self.session.execute( + select(image_tag.c.source).where( + and_( + image_tag.c.image_record_id == image_id, + image_tag.c.tag_id == tag_id, + ) + ) + ) + ).scalar_one_or_none() await self.session.execute( image_tag.delete().where( and_( @@ -232,6 +258,31 @@ class TagService: ) ) ) + if src == "head_auto": + await self._bump_metric(tag_id, "n_misfires") + + async def _note_under_fire(self, tag_id: int) -> None: + """Count an under-fire only when the tag actually has a head.""" + has_head = ( + await self.session.execute( + select(TagHead.tag_id).where(TagHead.tag_id == tag_id) + ) + ).first() is not None + if has_head: + await self._bump_metric(tag_id, "n_underfires") + + async def _bump_metric(self, tag_id: int, column: str) -> None: + """Increment a HeadMetric counter (upsert), keyed by tag so it survives + head retrain/prune.""" + col = HeadMetric.__table__.c[column] + await self.session.execute( + pg_insert(HeadMetric) + .values(tag_id=tag_id, **{column: 1}) + .on_conflict_do_update( + index_elements=["tag_id"], + set_={column: col + 1, "updated_at": func.now()}, + ) + ) async def list_for_image(self, image_id: int) -> Sequence: """Tags on an image, ordered (kind, name). Each row carries the fandom's diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 217b755..ddf9e08 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -846,6 +846,79 @@ def recover_stalled_head_auto_apply_runs() -> int: return recovered +# Keep ~6 months of daily head-metric snapshots (enough to see tuning trends). +HEAD_METRICS_SNAPSHOT_RETENTION_DAYS = 180 + + +@celery.task(name="backend.app.tasks.maintenance.snapshot_head_metrics") +def snapshot_head_metrics() -> int: + """Daily per-concept observability point (#114): record each head-bearing + concept's auto-applied volume, cumulative misfires/under-fires, and the + head's measured quality — the time-series the operator tunes from. Prunes + points older than the retention window.""" + from ..models import ( + HeadMetric, + HeadMetricsSnapshot, + Tag, + TagHead, + ) + from ..models.tag import image_tag + + SessionLocal = _sync_session_factory() + now = datetime.now(UTC) + with SessionLocal() as session: + heads = { + r.tag_id: r for r in session.execute( + select( + TagHead.tag_id, TagHead.ap, TagHead.precision_cv, + TagHead.recall, TagHead.n_pos, + ) + ) + } + metrics = { + r.tag_id: r for r in session.execute( + select( + HeadMetric.tag_id, HeadMetric.n_misfires, HeadMetric.n_underfires + ) + ) + } + applied = dict( + session.execute( + select(image_tag.c.tag_id, func.count()) + .where(image_tag.c.source == "head_auto") + .group_by(image_tag.c.tag_id) + ) + ) + tag_ids = set(heads) | set(metrics) + if not tag_ids: + return 0 + names = dict( + session.execute(select(Tag.id, Tag.name).where(Tag.id.in_(tag_ids))) + ) + for tid in tag_ids: + h = heads.get(tid) + m = metrics.get(tid) + session.add(HeadMetricsSnapshot( + tag_id=tid, name=names.get(tid, str(tid)), + snapshot_at=now, + n_auto_applied=applied.get(tid, 0), + n_misfires=m.n_misfires if m else 0, + n_underfires=m.n_underfires if m else 0, + ap=h.ap if h else None, + precision_cv=h.precision_cv if h else None, + recall=h.recall if h else None, + n_pos=h.n_pos if h else None, + )) + session.execute( + delete(HeadMetricsSnapshot).where( + HeadMetricsSnapshot.snapshot_at + < now - timedelta(days=HEAD_METRICS_SNAPSHOT_RETENTION_DAYS) + ) + ) + session.commit() + return len(tag_ids) + + @celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches") def recover_stalled_import_batches() -> int: """Finalize ImportBatch rows stuck in running past the hard limit diff --git a/tests/test_head_metrics.py b/tests/test_head_metrics.py new file mode 100644 index 0000000..189d197 --- /dev/null +++ b/tests/test_head_metrics.py @@ -0,0 +1,107 @@ +"""Auto-apply observability (#114): misfire/under-fire counters captured on +operator corrections, the daily snapshot time-series, and the metrics API.""" +import pytest +from sqlalchemy import select + +from backend.app.models import HeadMetric, HeadMetricsSnapshot, ImageRecord, TagHead, TagKind +from backend.app.models.tag import image_tag +from backend.app.services.tag_service import TagService + +pytestmark = pytest.mark.integration + + +async def _img(db, sha) -> ImageRecord: + img = ImageRecord( + path=f"/images/{sha}.jpg", sha256=sha, size_bytes=1, mime="image/jpeg", + width=1, height=1, origin="imported_filesystem", integrity_status="unknown", + ) + db.add(img) + await db.flush() + return img + + +def _head(tag_id): + return TagHead( + tag_id=tag_id, embedding_version="siglip-test", weights=[0.0] * 1152, + bias=0.0, suggest_threshold=0.5, auto_apply_threshold=0.6, + n_pos=30, n_neg=90, ap=0.9, precision_cv=0.95, recall=0.7, + ) + + +@pytest.mark.asyncio +async def test_removing_head_auto_tag_counts_misfire(db): + img = await _img(db, "a" * 64) + tag = await TagService(db).find_or_create("misfire", TagKind.general) + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=tag.id, source="head_auto", + )) + await db.commit() + await TagService(db).remove_from_image(img.id, tag.id) + await db.commit() + m = await db.get(HeadMetric, tag.id) + assert m is not None and m.n_misfires == 1 and m.n_underfires == 0 + + +@pytest.mark.asyncio +async def test_removing_manual_tag_is_not_a_misfire(db): + img = await _img(db, "b" * 64) + tag = await TagService(db).find_or_create("manualrm", TagKind.general) + await db.execute(image_tag.insert().values( + image_record_id=img.id, tag_id=tag.id, source="manual", + )) + await db.commit() + await TagService(db).remove_from_image(img.id, tag.id) + await db.commit() + assert await db.get(HeadMetric, tag.id) is None + + +@pytest.mark.asyncio +async def test_manual_add_with_head_counts_underfire(db): + img = await _img(db, "c" * 64) + tag = await TagService(db).find_or_create("underfire", TagKind.general) + db.add(_head(tag.id)) + await db.commit() + await TagService(db).add_to_image(img.id, tag.id, source="manual") + await db.commit() + m = await db.get(HeadMetric, tag.id) + assert m is not None and m.n_underfires == 1 + + +@pytest.mark.asyncio +async def test_manual_add_without_head_no_underfire(db): + img = await _img(db, "d" * 64) + tag = await TagService(db).find_or_create("nohead", TagKind.general) + await db.commit() + await TagService(db).add_to_image(img.id, tag.id, source="manual") + await db.commit() + assert await db.get(HeadMetric, tag.id) is None + + +@pytest.mark.asyncio +async def test_snapshot_records_timeseries_point(db): + tag = await TagService(db).find_or_create("snap", TagKind.general) + db.add(_head(tag.id)) + await db.commit() + from backend.app.tasks.maintenance import snapshot_head_metrics + + n = snapshot_head_metrics() # sync task, own session + assert n >= 1 + snaps = (await db.execute( + select(HeadMetricsSnapshot).where(HeadMetricsSnapshot.tag_id == tag.id) + )).scalars().all() + assert len(snaps) == 1 + assert snaps[0].name == "snap" + + +@pytest.mark.asyncio +async def test_metrics_api_returns_concept(client, db): + tag = await TagService(db).find_or_create("apimetric", TagKind.general) + db.add(_head(tag.id)) + await db.commit() + resp = await client.get("/api/heads/metrics") + assert resp.status_code == 200 + body = await resp.get_json() + c = next(x for x in body["concepts"] if x["name"] == "apimetric") + assert c["auto_apply"] is True + assert c["n_misfires"] == 0 + assert "snapshots" in body