diff --git a/MPCAutofill/cardpicker/local_detect_ai_art.py b/MPCAutofill/cardpicker/local_detect_ai_art.py index 0abf64d5d..be33191ca 100644 --- a/MPCAutofill/cardpicker/local_detect_ai_art.py +++ b/MPCAutofill/cardpicker/local_detect_ai_art.py @@ -19,26 +19,27 @@ that gate empirically after every write, reusing `purge_machine_votes.verify_no_machine_only_ resolutions` rather than re-deriving an equivalent check. -SENSITIVE-TAG DECISION (issue #261's own open question, resolved here): "AI-Generated" is -additionally marked `TagModerationClass.SENSITIVE` (see `cardpicker.sensitive_tags`) - a SECOND, -independent gate on top of the human-backed one above, requiring a privileged (moderator) -co-sign before ANY crowd consensus (human or mixed) on this tag can resolve at all. Mirrors the -precedent `sensitive_tags.py`'s own `"appropriate-bleed"` entry already set: a tag a machine can -usefully flag, but whose real-world consequence (here: publicly labelling a human artist's work -as AI-generated) carries enough reputational risk that even a confident crowd consensus must -wait for a human moderator, not just clear the ordinary human-backed threshold. The tag itself -was already seeded (as `TagModerationClass.STANDARD`, the model default) via `cardpicker. -default_tags.DEFAULT_TAGS`'s pre-existing `("AI-Generated", ["Midjourney"], None)` entry, which -exists for a DIFFERENT, orthogonal purpose - `cardpicker.tags.Tags`' filename-bracket matcher -(e.g. a source file literally named `"... [Midjourney].png"`) applies it directly to `Card.tags` -at import time, bypassing the vote system entirely, exactly as every other `DEFAULT_TAGS` entry -does. That pathway is untouched by this change (out of scope - it is not a machine "detection" -in this issue's sense, it is filename metadata the uploader supplied themselves) and is a -SEPARATE, deliberately-not-modified reason the tag continues to exist in `DEFAULT_TAGS` as well -as `SENSITIVE_TAGS` - `sensitive_tags.seed_sensitive_tags` upgrades whichever row already exists -(created by either seeding path, in either order - see that function's own idempotent -"upgrades moderation_class to SENSITIVE if it isn't already" contract) to SENSITIVE, so the -moderation gate ends up active regardless of seeding order. +SENSITIVE-TAG DECISION (issue #261's own open question, resolved in PR #263, THEN REVERSED by +owner decision 2026-07-21): "AI-Generated" briefly carried an additional `TagModerationClass. +SENSITIVE` marking (see `cardpicker.sensitive_tags`) - a second, independent gate requiring a +privileged (moderator) co-sign before ANY crowd consensus on this tag could resolve at all, +mirroring `sensitive_tags.py`'s own `"appropriate-bleed"` entry. The owner reverted that one +aspect (verbatim: "ordinary human votes is fine for AI I think. or at least not moderator eyes. +they will go contested if there is not an immediate human consensus that is the system working +as intended") - so this tag is now plain `TagModerationClass.STANDARD` again, exactly as it was +seeded (as the model default) via `cardpicker.default_tags.DEFAULT_TAGS`'s pre-existing +`("AI-Generated", ["Midjourney"], None)` entry, which exists for a DIFFERENT, orthogonal purpose +- `cardpicker.tags.Tags`' filename-bracket matcher (e.g. a source file literally named +`"... [Midjourney].png"`) applies it directly to `Card.tags` at import time, bypassing the vote +system entirely, untouched by any of this. The shared human-backed gate below (a lone machine +vote can never resolve ANY tag alone, regardless of moderation_class) is unaffected and remains +the only gate on this tag: an ordinary confident crowd consensus now resolves it same as any +other STANDARD tag, and a genuinely contested crowd stays CONTESTED/UNRESOLVED rather than +silently resolving wrong - the system working as intended, per the owner's own framing above. +A future privileged-co-sign requirement for this specific tag is tracked as a possible follow-up, +not built here - see `docs/features/moderation.md`'s AI-Generated paragraph. `sensitive_tags. +FORMERLY_SENSITIVE_TAG_NAMES` lets `seed_sensitive_tags` sync this downgrade on any instance that +already ran the #263-era seed and has the row stuck at SENSITIVE. OWNER AMENDMENT (2026-07-21, issue #261): generator-SITE URLs (e.g. CardConjurer.com) are EXCLUDED from the marker list - they identify a rendering/compositing TOOL usable with ordinary @@ -324,9 +325,9 @@ def run_ai_art_detector( purge_machine_votes.verify_no_machine_only_resolutions` rather than re-deriving an equivalent "is any touched card resolved on machine-only weight" check - see that function's own docstring for why "resolved to APPLY with only machine-sourced survivors" is the correct - invariant, not "never resolves at all" - the AI-Generated tag being additionally SENSITIVE - means it also can't resolve to APPLY without a privileged co-sign even once human-backed, an - even stronger guarantee than the shared human-backed gate alone provides). + invariant, not "never resolves at all"). "AI-Generated" is plain `TagModerationClass. + STANDARD` (see the module docstring's SENSITIVE-TAG DECISION section) - the shared + human-backed gate is this tag's only gate, same as any other STANDARD tag. """ run_id = run_id or generate_run_id() result = AiArtDetectorResult(dry_run=dry_run, run_id=run_id) @@ -334,8 +335,7 @@ def run_ai_art_detector( tag = Tag.objects.filter(name=AI_GENERATED_TAG_NAME).first() if tag is None: raise RuntimeError( - f"Tag {AI_GENERATED_TAG_NAME!r} does not exist yet - run `seed_default_tags` (and " - "`seed_sensitive_tags`, for the moderation gate) before this calculator." + f"Tag {AI_GENERATED_TAG_NAME!r} does not exist yet - run `seed_default_tags` " "before this calculator." ) votes_batch: list[CardTagVote] = [] diff --git a/MPCAutofill/cardpicker/management/commands/seed_sensitive_tags.py b/MPCAutofill/cardpicker/management/commands/seed_sensitive_tags.py index 98a2e6ee4..5a347139c 100644 --- a/MPCAutofill/cardpicker/management/commands/seed_sensitive_tags.py +++ b/MPCAutofill/cardpicker/management/commands/seed_sensitive_tags.py @@ -13,4 +13,7 @@ class Command(BaseCommand): def handle(self, *args: Any, **kwargs: Any) -> None: stats = seed_sensitive_tags() - print(f"Sensitive tags: {stats['created']} created, {stats['updated']} updated.") + print( + f"Sensitive tags: {stats['created']} created, {stats['updated']} updated, " + f"{stats['downgraded']} downgraded." + ) diff --git a/MPCAutofill/cardpicker/sensitive_tags.py b/MPCAutofill/cardpicker/sensitive_tags.py index 7bf3d3973..e35522714 100644 --- a/MPCAutofill/cardpicker/sensitive_tags.py +++ b/MPCAutofill/cardpicker/sensitive_tags.py @@ -41,22 +41,24 @@ # cast_bleed_edge_vote for the full rationale) than voting APPLY on the routine majority # ever was. Sensitive because a moderator co-sign is still required either direction. ("appropriate-bleed", "Verified to include the full bleed margin required for printing", "Appropriate Bleed"), - # Public issue #261 (2026-07-21): "AI-Generated" already exists as a STANDARD row via - # cardpicker.default_tags.DEFAULT_TAGS (seeded there for a DIFFERENT, orthogonal reason - - # cardpicker.tags.Tags' filename-bracket matcher applies it directly to Card.tags at import - # time from an uploader-supplied "[Midjourney]"-style bracket, bypassing the vote system - # entirely - untouched by this change). Listing it here upgrades that SAME row to SENSITIVE - # (see seed_sensitive_tags' own "upgrades moderation_class to SENSITIVE if it isn't already" - # contract - it runs regardless of which seeding path created the row first) once - # cardpicker.local_detect_ai_art starts casting real machine votes for it: mistagging a real - # human artist's work as AI-generated is a serious reputational harm to that artist, the same - # "machine can usefully flag it, but a moderator must still co-sign before it's public" logic - # already applied to appropriate-bleed above - a lone machine vote can never resolve this tag - # regardless (resolve_weighted_consensus's own human-backed gate), but a confident CROWD - # consensus alone isn't enough either; a privileged co-sign is required either direction. - ("AI-Generated", "Card art likely produced by an AI image generator", "AI-Generated"), ] +# Owner decision (2026-07-21, public issue #261 follow-up): "AI-Generated" is deliberately NOT +# listed in SENSITIVE_TAGS above - ordinary crowd consensus is fine for this tag, no moderator +# co-sign required. It was briefly listed here (PR #263) then reverted here; kept as its own +# named set (not just deleted from history) so `seed_sensitive_tags` can safely SYNC this one +# specific downgrade on any instance that already ran the #263 seed and has the row stuck at +# SENSITIVE - see that function's own "downgrades any FORMERLY_SENSITIVE_TAG_NAMES row still +# marked SENSITIVE" contract. Rationale (owner, verbatim): "ordinary human votes is fine for AI +# I think. or at least not moderator eyes. they will go contested if there is not an immediate +# human consensus that is the system working as intended" - i.e. resolve_weighted_consensus's +# ordinary human-backed gate (a lone machine vote can never resolve any tag alone, regardless of +# moderation_class - unchanged, see local_detect_ai_art.py) already produces the right behavior +# on a contested crowd: it stays UNRESOLVED/CONTESTED, not silently wrong. The privileged-co-sign +# idea for this tag specifically is tracked as a possible FUTURE enhancement, not built now - see +# docs/features/moderation.md's AI-Generated paragraph for the full writeup. +FORMERLY_SENSITIVE_TAG_NAMES: frozenset[str] = frozenset({"AI-Generated"}) + # Which sensitive tag each report reason argues for: reporting is "a positive CardTagVote on # the matching sensitive tag, plus the CardReport audit row" (see views.post_report_card). @@ -79,10 +81,18 @@ def seed_sensitive_tags() -> dict[str, int]: already - an instance that had a plain "NSFW" tag before this feature (e.g. hand-created, or a future seeding-order change) must end up with the gate active, or the moderation layer silently doesn't apply to the one tag it exists for. + + Also SYNCS THE REVERSE for `FORMERLY_SENSITIVE_TAG_NAMES` specifically: any of those exact + names still sitting at SENSITIVE (e.g. an instance that ran a prior seed while the name was + still listed in SENSITIVE_TAGS above) gets downgraded back to STANDARD. Deliberately scoped + to that named set, not "every SENSITIVE row not currently in SENSITIVE_TAGS" - a generic sync + would also clobber a tag an admin hand-set to SENSITIVE for an unrelated reason through the + Django admin, which this function has never had any business touching. """ created = 0 updated = 0 + downgraded = 0 for name, _description, display_name in SENSITIVE_TAGS: tag, was_created = Tag.objects.get_or_create( name=name, @@ -101,7 +111,14 @@ def seed_sensitive_tags() -> dict[str, int]: if update_fields: tag.save(update_fields=update_fields) updated += 1 - return {"created": created, "updated": updated} + + for name in FORMERLY_SENSITIVE_TAG_NAMES: + updated_count = Tag.objects.filter(name=name, moderation_class=TagModerationClass.SENSITIVE).update( + moderation_class=TagModerationClass.STANDARD + ) + downgraded += updated_count + + return {"created": created, "updated": updated, "downgraded": downgraded} -__all__ = ["seed_sensitive_tags", "SENSITIVE_TAGS"] +__all__ = ["seed_sensitive_tags", "SENSITIVE_TAGS", "FORMERLY_SENSITIVE_TAG_NAMES"] diff --git a/MPCAutofill/cardpicker/tests/test_local_detect_ai_art.py b/MPCAutofill/cardpicker/tests/test_local_detect_ai_art.py index 8d7c2c33e..209a8bc78 100644 --- a/MPCAutofill/cardpicker/tests/test_local_detect_ai_art.py +++ b/MPCAutofill/cardpicker/tests/test_local_detect_ai_art.py @@ -7,6 +7,7 @@ `test_local_calculate_verdicts.py` already establishes for this pipeline's later stages. """ +from cardpicker.default_tags import seed_default_tags from cardpicker.local_detect_ai_art import ( AI_ART_ANONYMOUS_ID, AI_ART_CONFIDENCE_MULTI_FIELD, @@ -42,6 +43,13 @@ def _seed_tag() -> Tag: + # AI-Generated is plain STANDARD (owner decision, 2026-07-21) - seeded via + # cardpicker.default_tags (its DEFAULT_TAGS entry, the filename-bracket-tagging path), not + # cardpicker.sensitive_tags. seed_sensitive_tags() is still called here too so this fixture + # also exercises FORMERLY_SENSITIVE_TAG_NAMES' downgrade-sync no-op path on a fresh/never- + # sensitive row (it's a harmless no-op either way, and matches real seeding order in + # management commands, which run both). + seed_default_tags() seed_sensitive_tags() return Tag.objects.get(name=AI_GENERATED_TAG_NAME) @@ -188,7 +196,9 @@ def test_dry_run_counts_without_writing(self, db): def test_write_casts_a_vote_and_never_resolves_alone(self, db): tag = _seed_tag() - assert tag.moderation_class == TagModerationClass.SENSITIVE + # owner decision 2026-07-21: AI-Generated is plain STANDARD, not SENSITIVE - ordinary + # crowd consensus is fine for this tag; see sensitive_tags.py's SENSITIVE_TAGS comment. + assert tag.moderation_class == TagModerationClass.STANDARD card = CardFactory(name="Some Card", content_phash=42) _evidence(card, legal_line_raw_text="2024 not for resale trademtgen midjourney") diff --git a/MPCAutofill/cardpicker/tests/test_sensitive_tags.py b/MPCAutofill/cardpicker/tests/test_sensitive_tags.py index f2d6941a1..5f392f9f5 100644 --- a/MPCAutofill/cardpicker/tests/test_sensitive_tags.py +++ b/MPCAutofill/cardpicker/tests/test_sensitive_tags.py @@ -1,6 +1,10 @@ from cardpicker.constants import NSFW from cardpicker.models import Tag, TagModerationClass -from cardpicker.sensitive_tags import SENSITIVE_TAGS, seed_sensitive_tags +from cardpicker.sensitive_tags import ( + FORMERLY_SENSITIVE_TAG_NAMES, + SENSITIVE_TAGS, + seed_sensitive_tags, +) class TestSeedSensitiveTags: @@ -24,7 +28,7 @@ def test_seeded_tags_are_sensitive_with_display_names(self, db): def test_rerunning_does_not_duplicate_or_touch_anything(self, db): seed_sensitive_tags() stats = seed_sensitive_tags() - assert stats == {"created": 0, "updated": 0} + assert stats == {"created": 0, "updated": 0, "downgraded": 0} assert Tag.objects.filter( name__in=[name for name, _description, _display_name in SENSITIVE_TAGS] ).count() == len(SENSITIVE_TAGS) @@ -53,3 +57,57 @@ def test_default_moderation_class_is_standard(self, db): assert Tag.objects.create(name="Some Ordinary Tag", aliases=[]).moderation_class == ( TagModerationClass.STANDARD ) + + def test_ai_generated_is_not_a_sensitive_tag(self, db): + # owner decision 2026-07-21 (public issue #261 follow-up): ordinary crowd consensus is + # fine for AI-Generated, no moderator co-sign required - see this list's own comment. + expected_names = {name for name, _description, _display_name in SENSITIVE_TAGS} + assert "AI-Generated" not in expected_names + + +class TestSeedSensitiveTagsDowngradesFormerlySensitive: + """Owner decision 2026-07-21: AI-Generated was upgraded to SENSITIVE by PR #263, then + reverted. `seed_sensitive_tags` must sync that reversal on any instance that already ran the + #263-era seed and is stuck with the row at SENSITIVE - a prod re-run of this same command is + the documented fix, not a fresh migration.""" + + def test_downgrades_a_row_stuck_at_sensitive_from_the_prior_seed(self, db): + Tag.objects.create(name="AI-Generated", aliases=["Midjourney"], moderation_class=TagModerationClass.SENSITIVE) + stats = seed_sensitive_tags() + assert stats["downgraded"] == 1 + assert Tag.objects.get(name="AI-Generated").moderation_class == TagModerationClass.STANDARD + + def test_rerunning_after_downgrade_is_a_no_op(self, db): + Tag.objects.create(name="AI-Generated", aliases=[], moderation_class=TagModerationClass.SENSITIVE) + seed_sensitive_tags() + stats = seed_sensitive_tags() + assert stats["downgraded"] == 0 + assert Tag.objects.get(name="AI-Generated").moderation_class == TagModerationClass.STANDARD + + def test_a_never_sensitive_row_is_left_alone(self, db): + # the common case: a fresh instance, or one that only ever seeded AI-Generated via + # seed_default_tags (STANDARD by model default) - no downgrade to report. + Tag.objects.create(name="AI-Generated", aliases=[], moderation_class=TagModerationClass.STANDARD) + stats = seed_sensitive_tags() + assert stats["downgraded"] == 0 + assert Tag.objects.get(name="AI-Generated").moderation_class == TagModerationClass.STANDARD + + def test_a_missing_row_is_not_created_by_the_downgrade_sync(self, db): + # seed_sensitive_tags must never create a FORMERLY_SENSITIVE_TAG_NAMES row that doesn't + # already exist - that taxonomy is owned by seed_default_tags now, not this module. + stats = seed_sensitive_tags() + assert stats["downgraded"] == 0 + assert not Tag.objects.filter(name="AI-Generated").exists() + + def test_unrelated_sensitive_tag_is_never_touched_by_the_downgrade_sync(self, db): + # a hand-set SENSITIVE tag with a name this taxonomy has never managed must be immune - + # the sync is scoped to FORMERLY_SENSITIVE_TAG_NAMES by exact name, never "every + # SENSITIVE row not currently in SENSITIVE_TAGS". + Tag.objects.create( + name="Some Admin Set This Sensitive", aliases=[], moderation_class=TagModerationClass.SENSITIVE + ) + seed_sensitive_tags() + assert Tag.objects.get(name="Some Admin Set This Sensitive").moderation_class == TagModerationClass.SENSITIVE + + def test_formerly_sensitive_tag_names_contains_ai_generated(self): + assert FORMERLY_SENSITIVE_TAG_NAMES == frozenset({"AI-Generated"}) diff --git a/docs/features/moderation.md b/docs/features/moderation.md index 3f4c2f03f..ba6e36e3a 100644 --- a/docs/features/moderation.md +++ b/docs/features/moderation.md @@ -116,7 +116,7 @@ and need nothing. ## Sensitive taxonomy `Tag.moderation_class` (`standard` | `sensitive`, default standard). -`manage.py seed_sensitive_tags` seeds five (command, not data migration — +`manage.py seed_sensitive_tags` seeds four (command, not data migration — same rationale as the other taxonomies, see [[printing-tags.md]]): | name | display name | report reason | @@ -125,7 +125,6 @@ same rationale as the other taxonomies, see [[printing-tags.md]]): | `low-res` | Low quality | `low_quality` | | `incorrect-info` | Incorrect card info | `wrong_card` | | `appropriate-bleed` | Appropriate Bleed | — (no chip) | -| `AI-Generated` | AI-Generated | — (no chip) | `appropriate-bleed` is deliberately the **positive** framing ("verified to include the full bleed margin required for printing") rather than a negative @@ -149,27 +148,37 @@ lowercased key). Seeding upgrades a pre-existing standard row to sensitive but never clobbers a manually edited `display_name`. Names are immutable federation contracts. -`AI-Generated` (public issue #261, 2026-07-21) was upgraded to sensitive from -its pre-existing `standard` row (seeded by `cardpicker.default_tags. DEFAULT_TAGS` for a different, orthogonal reason — filename-bracket tagging, -e.g. `[Midjourney]`, applies it directly at import time, exactly like -`[NSFW]` above, untouched by this change). The upgrade exists because -`cardpicker.local_detect_ai_art` now casts real machine votes for it — a -calculator that scans already-stored OCR evidence (artist credit line, legal -line, collector line) for known AI-generator marker strings (Midjourney, -DALL-E, Stable Diffusion, SDXL, Gemini, Imagen, Adobe Firefly, Leonardo AI, -NightCafe, Bing Image Creator, "AI art"/"AI generated" — deliberately -excluding generator-SITE/tool names like CardConjurer, which indicate a -rendering tool usable with ordinary human art, not AI provenance) under its -own machine identity (`ai-art-detector-v1`, `VoteSource.OCR`). Same -`appropriate-bleed` logic applies here, doubled: a lone machine vote can -never resolve any tag at all (the shared human-backed gate), and even a -confident crowd consensus on this one specifically still needs a moderator -co-sign before it goes live — publicly labelling a real human artist's work -as AI-generated is a reputational harm serious enough to warrant the same -"machine can flag it, human must confirm it" treatment as the mature-content -and quality tags above, not just the ordinary crowd threshold. Positive- -detection only: a missing marker proves nothing, so this calculator never -casts a negative vote, only `APPLY`. +`AI-Generated` (public issue #261) was briefly upgraded to sensitive +(2026-07-21, this same day) from its pre-existing `standard` row (seeded by +`cardpicker.default_tags.DEFAULT_TAGS` for a different, orthogonal reason — +filename-bracket tagging, e.g. `[Midjourney]`, applies it directly at +import time, exactly like `[NSFW]` above, untouched by any of this) — +then reverted back to `standard` the same day by owner decision, once +`cardpicker.local_detect_ai_art` (a calculator that scans already-stored OCR +evidence — artist credit line, legal line, collector line — for known +AI-generator marker strings: Midjourney, DALL-E, Stable Diffusion, SDXL, +Gemini, Imagen, Adobe Firefly, Leonardo AI, NightCafe, Bing Image Creator, +"AI art"/"AI generated" — deliberately excluding generator-SITE/tool names +like CardConjurer, which indicate a rendering tool usable with ordinary +human art, not AI provenance — and casts votes for it under its own machine +identity `ai-art-detector-v1`, `VoteSource.OCR`) went live and the owner +weighed in on the open question that upgrade had been guessing at (verbatim): +"ordinary human votes is fine for AI I think. or at least not moderator +eyes. they will go contested if there is not an immediate human consensus +that is the system working as intended." So `AI-Generated` now behaves like +any other `standard` tag: the shared human-backed gate is unchanged (a lone +machine vote still can never resolve any tag at all, regardless of +moderation_class), but an ordinary confident crowd consensus resolves it +without a moderator co-sign, and a genuinely contested crowd stays +contested — which is the intended outcome, not a gap. A future +privileged-co-sign requirement specifically for this tag remains a +possible follow-up idea, not built now. `cardpicker.sensitive_tags. FORMERLY_SENSITIVE_TAG_NAMES` (currently just `{"AI-Generated"}`) lets +`seed_sensitive_tags` sync this downgrade on any instance that already ran +the brief sensitive-era seed and has the row stuck at `sensitive` — running +`manage.py seed_sensitive_tags` again reports it as `downgraded`, alongside +the usual `created`/`updated` counts. Positive-detection only, unchanged: +a missing marker proves nothing, so this calculator never casts a negative +vote, only `APPLY`. Two knock-ons worth knowing: the seeded tags become visible/votable in the card modal's tag grid for everyone (intended — votes accumulate as pending),