Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 25 additions & 25 deletions MPCAutofill/cardpicker/local_detect_ai_art.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -324,18 +325,17 @@ 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)

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] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
49 changes: 33 additions & 16 deletions MPCAutofill/cardpicker/sensitive_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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,
Expand All @@ -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"]
12 changes: 11 additions & 1 deletion MPCAutofill/cardpicker/tests/test_local_detect_ai_art.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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")

Expand Down
62 changes: 60 additions & 2 deletions MPCAutofill/cardpicker/tests/test_sensitive_tags.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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"})
Loading
Loading