feat(allowlist): coverage projection + applied-count + post-accept projection (#7a/#7b)
Cluster B, milestone #99. Backend for the allowlist tuning dashboard. #7a: AllowlistService.coverage(tag_id, threshold) counts distinct images with a prediction resolving to the tag (raw_name==tag.name OR (raw_name,category) in the tag's aliases) scoring >= threshold — the gross candidate pool, mirroring tasks.ml._confidence_for_tag resolution. list_all now carries applied_count (grouped image_tag count) + coverage_count (at the row's threshold). New GET /api/tags/<id>/allowlist/coverage?threshold= for the live what-if number. #7b: /suggestions/accept + /alias return {allowlisted, tag_id, tag_name, projected_count} (projection at the tag's threshold) instead of 204, so the UI can show a non-blocking 'auto-applying to ~N images' toast. Apply still runs async via apply_allowlist_tags — projected_count is an estimate. Tests: coverage by threshold (direct + alias-with-category), list applied vs coverage, coverage route (explicit/default/bad threshold), accept/alias payload (newly-allowlisted vs already-on-list). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models import TagAllowlist, TagKind, TagSuggestionRejection
|
||||
from backend.app.models import (
|
||||
ImagePrediction,
|
||||
TagAlias,
|
||||
TagAllowlist,
|
||||
TagKind,
|
||||
TagSuggestionRejection,
|
||||
)
|
||||
from backend.app.models.tag import image_tag
|
||||
from backend.app.services.ml.allowlist import AllowlistService
|
||||
from backend.app.services.tag_service import TagService
|
||||
@@ -9,10 +15,10 @@ from backend.app.services.tag_service import TagService
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def _make_image(db):
|
||||
async def _make_image(db, sha: str = "x" * 64):
|
||||
from backend.app.models import ImageRecord
|
||||
img = ImageRecord(
|
||||
path="/images/x.jpg", sha256="x" * 64, size_bytes=1,
|
||||
path=f"/images/{sha[:8]}.jpg", sha256=sha, size_bytes=1,
|
||||
mime="image/jpeg", width=1, height=1,
|
||||
origin="imported_filesystem", integrity_status="unknown",
|
||||
)
|
||||
@@ -21,6 +27,14 @@ async def _make_image(db):
|
||||
return img
|
||||
|
||||
|
||||
async def _add_pred(db, image_id, raw_name, score, category="general"):
|
||||
db.add(ImagePrediction(
|
||||
image_record_id=image_id, raw_name=raw_name,
|
||||
category=category, score=score,
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_applies_and_allowlists(db):
|
||||
img = await _make_image(db)
|
||||
@@ -106,6 +120,52 @@ async def test_update_threshold_and_remove(db):
|
||||
assert await db.get(TagAllowlist, tag.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coverage_by_threshold_direct_name(db):
|
||||
tag = await TagService(db).find_or_create("Cov", TagKind.general)
|
||||
svc = AllowlistService(db)
|
||||
for i, score in enumerate((0.95, 0.80, 0.60)):
|
||||
img = await _make_image(db, sha=f"c{i:063d}")
|
||||
await _add_pred(db, img.id, "Cov", score)
|
||||
assert await svc.coverage(tag.id, 0.90) == 1
|
||||
assert await svc.coverage(tag.id, 0.70) == 2
|
||||
assert await svc.coverage(tag.id, 0.50) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coverage_via_alias_respects_category(db):
|
||||
tag = await TagService(db).find_or_create("Aliased", TagKind.character)
|
||||
db.add(TagAlias(
|
||||
alias_string="model_key", alias_category="character",
|
||||
canonical_tag_id=tag.id,
|
||||
))
|
||||
await db.flush()
|
||||
svc = AllowlistService(db)
|
||||
hit = await _make_image(db, sha=f"a{0:063d}")
|
||||
await _add_pred(db, hit.id, "model_key", 0.92, category="character")
|
||||
# Same alias string but wrong category must NOT resolve to the tag.
|
||||
miss = await _make_image(db, sha=f"a{1:063d}")
|
||||
await _add_pred(db, miss.id, "model_key", 0.99, category="general")
|
||||
assert await svc.coverage(tag.id, 0.90) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_reports_applied_and_coverage(db):
|
||||
tag = await TagService(db).find_or_create("Both", TagKind.general)
|
||||
svc = AllowlistService(db)
|
||||
applied_img = await _make_image(db, sha=f"b{0:063d}")
|
||||
await svc.accept(applied_img.id, tag.id) # applies + allowlists
|
||||
await _add_pred(db, applied_img.id, "Both", 0.95)
|
||||
# A second image only has a qualifying prediction (covered, not applied).
|
||||
cov_img = await _make_image(db, sha=f"b{1:063d}")
|
||||
await _add_pred(db, cov_img.id, "Both", 0.95)
|
||||
|
||||
rows = await svc.list_all()
|
||||
row = next(r for r in rows if r.tag_id == tag.id)
|
||||
assert row.applied_count == 1 # only the accepted image
|
||||
assert row.coverage_count == 2 # both have a ≥threshold pred
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_threshold_clamped_to_store_floor(db):
|
||||
# A min_confidence below the store floor (default 0.70) is clamped up —
|
||||
|
||||
Reference in New Issue
Block a user