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
4 changes: 4 additions & 0 deletions MPCAutofill/MPCAutofill/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
PRINTING_TAG_MIN_SHARE = env.float("PRINTING_TAG_MIN_SHARE", default=0.6)
PRINTING_TAG_ADMIN_WEIGHT = env.float("PRINTING_TAG_ADMIN_WEIGHT", default=5)
PRINTING_TAG_AI_WEIGHT = env.float("PRINTING_TAG_AI_WEIGHT", default=0.5)
# federation-readiness stub (see docs/federation-v1.md) - no import path creates federated
# votes yet, so this setting is currently inert, but it's wired into vote_consensus._SOURCE_WEIGHTS
# alongside the weights above.
VOTE_FEDERATED_WEIGHT = env.float("VOTE_FEDERATED_WEIGHT", default=1.0)
# django-ratelimit rate string (see cardpicker.views.post_submit_printing_tag), keyed by the
# client-generated anonymous ID (IP as a fallback if that header is somehow missing).
PRINTING_TAG_SUBMISSION_RATE = env("PRINTING_TAG_SUBMISSION_RATE", default="20/h")
Expand Down
71 changes: 70 additions & 1 deletion MPCAutofill/cardpicker/admin.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
from functools import reduce
from operator import or_

from django.contrib import admin
from django.db.models import QuerySet
from django.db.models import Q, QuerySet
from django.http import HttpRequest

from .artist_consensus import get_contested_artist_card_ids
from .models import (
CanonicalArtist,
CanonicalCard,
CanonicalExpansion,
CanonicalPrintingMetadata,
Card,
CardArtistVote,
CardPrintingTag,
CardTagVote,
DFCPair,
Project,
ProjectMember,
Expand All @@ -19,6 +25,7 @@
)
from .printing_consensus import get_contested_card_ids
from .sources.update_database import update_database
from .tag_consensus import get_contested_tag_pairs


# Register your models here.
Expand Down Expand Up @@ -131,6 +138,68 @@ class AdminCardPrintingTag(admin.ModelAdmin[CardPrintingTag]):
raw_id_fields = ["card", "printing"]


class ContestedArtistFilter(admin.SimpleListFilter):
"""Admin-triage wrapper around `cardpicker.artist_consensus.get_contested_artist_card_ids` -
mirrors `ContestedCardFilter` exactly, generalized to artist votes."""

title = "contested"
parameter_name = "contested"

def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin[CardArtistVote]) -> list[tuple[str, str]]:
return [("yes", "Yes")]

def queryset(self, request: HttpRequest, queryset: QuerySet[CardArtistVote]) -> QuerySet[CardArtistVote]:
if self.value() != "yes":
return queryset
return queryset.filter(card_id__in=get_contested_artist_card_ids())


class ContestedTagFilter(admin.SimpleListFilter):
"""Admin-triage wrapper around `cardpicker.tag_consensus.get_contested_tag_pairs` - same
idea as `ContestedCardFilter`, but the unit is a (card, tag) pair rather than just a card,
so the queryset filter is an OR of per-pair conditions rather than a plain `card_id__in`."""

title = "contested"
parameter_name = "contested"

def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin[CardTagVote]) -> list[tuple[str, str]]:
return [("yes", "Yes")]

def queryset(self, request: HttpRequest, queryset: QuerySet[CardTagVote]) -> QuerySet[CardTagVote]:
if self.value() != "yes":
return queryset
pairs = get_contested_tag_pairs()
if not pairs:
return queryset.none()
condition = reduce(or_, (Q(card_id=card_id, tag_id=tag_id) for card_id, tag_id in pairs))
return queryset.filter(condition)


@admin.register(CardArtistVote)
class AdminCardArtistVote(admin.ModelAdmin[CardArtistVote]):
list_display = (
"card",
"artist",
"is_unknown",
"source",
"peer",
"confidence",
"anonymous_id",
"created_at",
)
list_filter = ("source", "is_unknown", "peer", ContestedArtistFilter)
search_fields = ("card__name",)
raw_id_fields = ["card", "artist"]


@admin.register(CardTagVote)
class AdminCardTagVote(admin.ModelAdmin[CardTagVote]):
list_display = ("card", "tag", "polarity", "source", "peer", "confidence", "anonymous_id", "created_at")
list_filter = ("source", "polarity", "peer", ContestedTagFilter)
search_fields = ("card__name", "tag__name")
raw_id_fields = ["card", "tag"]


@admin.register(TagAliasSuggestion)
class AdminTagAliasSuggestion(admin.ModelAdmin[TagAliasSuggestion]):
list_display = ("raw_text", "suggested_tag", "confidence", "occurrence_count", "status")
Expand Down
148 changes: 148 additions & 0 deletions MPCAutofill/cardpicker/artist_consensus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
from typing import Literal, TypedDict

from django.conf import settings

from cardpicker.models import (
ArtistVoteStatus,
CanonicalArtist,
Card,
CardArtistVote,
VoteSource,
)
from cardpicker.vote_consensus import (
_SOURCE_WEIGHTS,
VoteTuple,
contested_queryset,
resolve_weighted_consensus,
)

UNKNOWN: Literal["UNKNOWN"] = "UNKNOWN"


def resolve_artist(card: Card) -> CanonicalArtist | Literal["UNKNOWN"] | None:
"""
Reconciles all `CardArtistVote` votes cast against `card` into a single resolved outcome:
a specific `CanonicalArtist`, the `UNKNOWN` sentinel (consensus is that the artist is
unlisted/unidentifiable), or `None` if there isn't yet enough signal. Mirrors
`cardpicker.printing_consensus.resolve_printing` exactly, built on the same shared
`resolve_weighted_consensus` core.

Note this outcome is only ever surfaced to a viewer when the card's printing-tag
consensus *hasn't* resolved a printing - see the artist fallback chain in
`Card.serialise()`, where a resolved printing's own artist always takes precedence.
"""
votes = list(card.artist_votes.all())
if not votes:
return None

artists_by_id: dict[int, CanonicalArtist] = {}
vote_tuples: list[VoteTuple] = []
for vote in votes:
key: int | Literal["UNKNOWN"]
if vote.is_unknown:
key = UNKNOWN
else:
# guaranteed non-null here by the model's artist_xor_unknown CheckConstraint
assert vote.artist_id is not None
assert vote.artist is not None
key = vote.artist_id
artists_by_id[vote.artist_id] = vote.artist
vote_tuples.append(
VoteTuple(
outcome_key=key,
weight=_SOURCE_WEIGHTS[vote.source],
is_human_backed=vote.source != VoteSource.AI,
)
)

winning_key = resolve_weighted_consensus(
vote_tuples, min_weight=settings.PRINTING_TAG_MIN_VOTES, min_share=settings.PRINTING_TAG_MIN_SHARE
)
if winning_key is None:
return None
if winning_key == UNKNOWN:
return UNKNOWN
assert isinstance(winning_key, int)
return artists_by_id[winning_key]


def resolve_and_persist_artist(card: Card) -> CanonicalArtist | Literal["UNKNOWN"] | None:
"""
Runs `resolve_artist(card)` and writes the outcome onto `card.inferred_canonical_artist`
and `card.artist_vote_status` together - same pattern as
`cardpicker.printing_consensus.resolve_and_persist_printing`. Deliberately doesn't consult
`card.printing_tag_status` at all: the precedence rule ("a resolved printing's artist wins")
is enforced entirely by `Card.serialise()`'s fallback chain, not here, so this function
stays decoupled from printing-tag state.

When unresolved, additionally distinguishes `CONTESTED` (more than one distinct outcome
has votes) from plain `UNRESOLVED` (not enough votes yet to conclude anything) - a second,
lightweight query, only taken on this branch, so the common resolved case pays nothing
extra for it.
"""
result = resolve_artist(card)
if result is None:
distinct_outcomes = {
UNKNOWN if is_unknown else artist_id
for is_unknown, artist_id in card.artist_votes.values_list("is_unknown", "artist_id")
}
card.inferred_canonical_artist = None
card.artist_vote_status = (
ArtistVoteStatus.CONTESTED if len(distinct_outcomes) > 1 else ArtistVoteStatus.UNRESOLVED
)
elif result == UNKNOWN:
card.inferred_canonical_artist = None
card.artist_vote_status = ArtistVoteStatus.UNKNOWN
else:
card.inferred_canonical_artist = result
card.artist_vote_status = ArtistVoteStatus.RESOLVED
card.save(update_fields=["inferred_canonical_artist", "artist_vote_status"])
return result


class ArtistVoteTallyEntry(TypedDict):
artist: CanonicalArtist | None
is_unknown: bool
count: int


def get_artist_vote_tally(card: Card) -> list[ArtistVoteTallyEntry]:
"""
Plain, unweighted per-outcome vote count for `card` - mirrors
`cardpicker.printing_consensus.get_vote_tally`, for showing a voter what's already been
said before they confirm or dispute it.
"""
tally: dict[int | Literal["UNKNOWN"], ArtistVoteTallyEntry] = {}
for vote in card.artist_votes.all():
key: int | Literal["UNKNOWN"]
if vote.is_unknown:
key = UNKNOWN
else:
assert vote.artist_id is not None
key = vote.artist_id
if key not in tally:
tally[key] = ArtistVoteTallyEntry(artist=vote.artist, is_unknown=vote.is_unknown, count=0)
tally[key]["count"] += 1
return sorted(tally.values(), key=lambda entry: entry["count"], reverse=True)


def get_contested_artist_card_ids() -> list[int]:
"""
IDs of cards with conflicting artist votes on record - mirrors
`cardpicker.printing_consensus.get_contested_card_ids` exactly, generalized via
`vote_consensus.contested_queryset`. See that function's docstring for what "contested"
means here and why this is a cheap proxy, not a full consensus recomputation.
"""
return contested_queryset(
CardArtistVote.objects.all(), group_by="card_id", outcome_field="artist_id", sentinel_field="is_unknown"
)


__all__ = [
"UNKNOWN",
"resolve_artist",
"resolve_and_persist_artist",
"get_artist_vote_tally",
"get_contested_artist_card_ids",
"ArtistVoteTallyEntry",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Generated by Django 4.2.30 on 2026-07-12 18:10

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("cardpicker", "0052_tagaliassuggestion_card_expansion_hint"),
]

operations = [
migrations.AddField(
model_name="card",
name="artist_vote_status",
field=models.CharField(
choices=[("unresolved", "Unresolved"), ("resolved", "Resolved"), ("unknown", "Unknown")],
db_index=True,
default="unresolved",
max_length=10,
),
),
migrations.AddField(
model_name="card",
name="inferred_canonical_artist",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="cardpicker.canonicalartist",
),
),
migrations.CreateModel(
name="CardTagVote",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("anonymous_id", models.CharField(max_length=40)),
(
"source",
models.CharField(
choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI")], default="user", max_length=10
),
),
("confidence", models.FloatField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("polarity", models.SmallIntegerField(choices=[(1, "Apply"), (-1, "Not applicable")])),
(
"card",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="tag_votes", to="cardpicker.card"
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="votes", to="cardpicker.tag"
),
),
],
),
migrations.CreateModel(
name="CardArtistVote",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("anonymous_id", models.CharField(max_length=40)),
(
"source",
models.CharField(
choices=[("user", "User"), ("admin", "Admin"), ("ai", "AI")], default="user", max_length=10
),
),
("confidence", models.FloatField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("is_unknown", models.BooleanField(default=False)),
(
"artist",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="votes",
to="cardpicker.canonicalartist",
),
),
(
"card",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="artist_votes", to="cardpicker.card"
),
),
],
),
migrations.AddConstraint(
model_name="cardtagvote",
constraint=models.UniqueConstraint(fields=("card", "tag", "anonymous_id"), name="cardtagvote_unique_vote"),
),
migrations.AddConstraint(
model_name="cardartistvote",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("artist__isnull", False), ("is_unknown", False)),
models.Q(("artist__isnull", True), ("is_unknown", True)),
_connector="OR",
),
name="cardartistvote_artist_xor_unknown",
),
),
migrations.AddConstraint(
model_name="cardartistvote",
constraint=models.UniqueConstraint(
condition=models.Q(("is_unknown", False)),
fields=("card", "artist", "anonymous_id"),
name="cardartistvote_unique_artist_vote",
),
),
migrations.AddConstraint(
model_name="cardartistvote",
constraint=models.UniqueConstraint(
condition=models.Q(("is_unknown", True)),
fields=("card", "anonymous_id"),
name="cardartistvote_unique_unknown_vote",
),
),
]
Loading
Loading