feat(heads): auto-apply observability + on by default (#114 auto-apply B)
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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
+103
-1
@@ -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
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user