feat(ui): confirm/keep auto-applied tags (milestone 139)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 28s
CI / integration (push) Successful in 3m39s

Auto-applied tags are provisional (they don't train the model + can be retracted
until confirmed), so surface and confirm them:
- Backend: list_for_image + get_image_with_tags now include `source` + a
  `confirmed` flag on each applied tag (via serialize_tag, image-scoped; defaulted
  for autocomplete/directory callers).
- Frontend: TagChip badges an unconfirmed auto-tag with an "auto" pill + a
  one-click Keep/confirm (✓) → POST /images/<id>/tags/<id>/confirm, which promotes
  it to a training positive and shields it from the retraction sweep; TagPanel
  reloads so the badge + button drop once confirmed.
Contract test for the source/confirmed payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-06 18:51:39 -04:00
parent d3984ccb0d
commit 775941609d
6 changed files with 131 additions and 7 deletions
+32
View File
@@ -141,6 +141,38 @@ async def test_applied_tag_grounding_returns_winning_region(client, db):
assert body["grounding"]["kind"] == "concept"
@pytest.mark.asyncio
async def test_applied_tags_expose_source_and_confirmed(client, db):
# The chip UI needs each applied tag's source (auto vs manual) + confirmed
# state to badge auto-tags and offer Keep/confirm (milestone 139).
from backend.app.models import TagPositiveConfirmation
from backend.app.models.tag import image_tag
img = ImageRecord(
path="/images/srcflag.jpg", sha256="sf" * 32, size_bytes=1,
mime="image/jpeg", width=1, height=1, origin="imported_filesystem",
integrity_status="unknown",
)
db.add(img)
await db.flush()
auto = await TagService(db).find_or_create("autotag", TagKind.general)
manual = await TagService(db).find_or_create("manualtag", TagKind.general)
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=auto.id, source="head_auto"))
await db.execute(image_tag.insert().values(
image_record_id=img.id, tag_id=manual.id, source="manual"))
db.add(TagPositiveConfirmation(image_record_id=img.id, tag_id=manual.id))
await db.commit()
resp = await client.get(f"/api/images/{img.id}/tags")
assert resp.status_code == 200
by_id = {t["id"]: t for t in await resp.get_json()}
assert by_id[auto.id]["source"] == "head_auto"
assert by_id[auto.id]["confirmed"] is False
assert by_id[manual.id]["source"] == "manual"
assert by_id[manual.id]["confirmed"] is True # has a confirmation row
@pytest.mark.asyncio
async def test_applied_tag_grounding_no_head(client, db):
# A tag with no head can't be localized → has_head False, grounding null; the