"""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