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",
|
"ml_settings",
|
||||||
sa.Column(
|
sa.Column(
|
||||||
"head_auto_apply_enabled", sa.Boolean(), nullable=False,
|
"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(
|
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 sqlalchemy import desc, func, select
|
||||||
|
|
||||||
from ..extensions import get_session
|
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 (
|
from ..services.ml.heads import (
|
||||||
HeadAutoApplyAlreadyRunning,
|
HeadAutoApplyAlreadyRunning,
|
||||||
HeadAutoApplyDisabled,
|
HeadAutoApplyDisabled,
|
||||||
@@ -181,3 +189,97 @@ async def auto_apply_status():
|
|||||||
"running_id": running,
|
"running_id": running,
|
||||||
"runs": [_serialize_apply_run(r) for r in runs],
|
"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",
|
"task": "backend.app.tasks.ml.scheduled_apply_head_tags",
|
||||||
"schedule": 86400.0, # no-op unless head_auto_apply_enabled
|
"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": {
|
"integrity-verify-weekly": {
|
||||||
"task": "backend.app.tasks.maintenance.verify_integrity",
|
"task": "backend.app.tasks.maintenance.verify_integrity",
|
||||||
"schedule": 604800.0, # weekly
|
"schedule": 604800.0, # weekly
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from .credential import Credential
|
|||||||
from .download_event import DownloadEvent
|
from .download_event import DownloadEvent
|
||||||
from .external_link import ExternalLink
|
from .external_link import ExternalLink
|
||||||
from .head_auto_apply_run import HeadAutoApplyRun
|
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 .head_training_run import HeadTrainingRun
|
||||||
from .image_prediction import ImagePrediction
|
from .image_prediction import ImagePrediction
|
||||||
from .image_provenance import ImageProvenance
|
from .image_provenance import ImageProvenance
|
||||||
@@ -69,6 +71,8 @@ __all__ = [
|
|||||||
"LibraryAuditRun",
|
"LibraryAuditRun",
|
||||||
"MLSettings",
|
"MLSettings",
|
||||||
"HeadAutoApplyRun",
|
"HeadAutoApplyRun",
|
||||||
|
"HeadMetric",
|
||||||
|
"HeadMetricsSnapshot",
|
||||||
"HeadTrainingRun",
|
"HeadTrainingRun",
|
||||||
"TagAlias",
|
"TagAlias",
|
||||||
"TagAllowlist",
|
"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
|
Float, nullable=False, default=0.97
|
||||||
)
|
)
|
||||||
# Earned auto-apply (#114). A graduated head fires (tags images without a
|
# 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
|
# 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
|
# under-supported low-N head can't spray tags across the library. ON by
|
||||||
# default; the operator enables after previewing. Operator-tunable.
|
# 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(
|
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(
|
head_auto_apply_min_positives: Mapped[int] = mapped_column(
|
||||||
Integer, nullable=False, default=30
|
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.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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_allowlist import TagAllowlist
|
||||||
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
from ..models.tag_reference_embedding import TagReferenceEmbedding
|
||||||
from .db_helpers import get_or_create
|
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:
|
async def add_to_image(self, image_id: int, tag_id: int, source: str = "manual") -> None:
|
||||||
"""Idempotent: re-adding an existing tag does nothing."""
|
"""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(
|
stmt = pg_insert(image_tag).values(
|
||||||
image_record_id=image_id, tag_id=tag_id, source=source
|
image_record_id=image_id, tag_id=tag_id, source=source
|
||||||
)
|
)
|
||||||
@@ -222,8 +234,22 @@ class TagService:
|
|||||||
index_elements=["image_record_id", "tag_id"]
|
index_elements=["image_record_id", "tag_id"]
|
||||||
)
|
)
|
||||||
await self.session.execute(stmt)
|
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:
|
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(
|
await self.session.execute(
|
||||||
image_tag.delete().where(
|
image_tag.delete().where(
|
||||||
and_(
|
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:
|
async def list_for_image(self, image_id: int) -> Sequence:
|
||||||
"""Tags on an image, ordered (kind, name). Each row carries the fandom's
|
"""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
|
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")
|
@celery.task(name="backend.app.tasks.maintenance.recover_stalled_import_batches")
|
||||||
def recover_stalled_import_batches() -> int:
|
def recover_stalled_import_batches() -> int:
|
||||||
"""Finalize ImportBatch rows stuck in running past the hard limit
|
"""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