From 6d044e53af5813ccff66d62ba6231055161d5936 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:50:16 +0000 Subject: [PATCH 1/2] Differentiate 'Not sure' from 'Skip': record a human abstention (issue #712) 'Not sure' means a voter engaged and found the image genuinely ambiguous - information about the card. 'Skip' carries no signal at all. Both used to be no-ops; now 'Not sure' POSTs 2/submitQuestionAbstention/ and is recorded in a new CardQuestionAbstention row (card, anonymous_id, question_type, unique together, get_or_create-idempotent) before the existing Level 1 -> Level 2 transition. 'Skip' is unchanged. CardQuestionAbstention is the human counterpart to CardScanLog's machine abstention and is deliberately a separate model, not a subclass of AbstractWeightedVote - an abstention is not a vote. --- MPCAutofill/cardpicker/admin.py | 9 ++ .../migrations/0104_cardquestionabstention.py | 39 +++++++ MPCAutofill/cardpicker/models.py | 55 +++++++++ MPCAutofill/cardpicker/schema_types.py | 46 ++++++++ .../tests/test_question_abstention.py | 107 ++++++++++++++++++ MPCAutofill/cardpicker/urls.py | 1 + MPCAutofill/cardpicker/views.py | 33 ++++++ docs/features/printing-tags.md | 17 +++ frontend/src/common/schema_types.ts | 16 +++ .../questionFeed/QuestionFeed.test.tsx | 74 ++++++++++++ .../features/questionFeed/QuestionFeed.tsx | 18 ++- frontend/src/store/api.ts | 30 +++++ 12 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 MPCAutofill/cardpicker/migrations/0104_cardquestionabstention.py create mode 100644 MPCAutofill/cardpicker/tests/test_question_abstention.py diff --git a/MPCAutofill/cardpicker/admin.py b/MPCAutofill/cardpicker/admin.py index 6bf768685..6a249e0ee 100644 --- a/MPCAutofill/cardpicker/admin.py +++ b/MPCAutofill/cardpicker/admin.py @@ -15,6 +15,7 @@ CardArtistVote, CardIllustrationVote, CardPrintingTag, + CardQuestionAbstention, CardReport, CardScanLog, CardTagVote, @@ -300,6 +301,14 @@ class AdminCardScanLog(admin.ModelAdmin[CardScanLog]): ordering = ("-scanned_at",) +@admin.register(CardQuestionAbstention) +class AdminCardQuestionAbstention(admin.ModelAdmin[CardQuestionAbstention]): + list_display = ("card", "anonymous_id", "question_type", "created_at") + list_filter = ("question_type",) + search_fields = ("anonymous_id",) + ordering = ("-created_at",) + + @admin.register(EnvelopeTrip) class AdminEnvelopeTrip(admin.ModelAdmin[EnvelopeTrip]): # Stage E Phase 1 (docs/proposals/stage-e-streaming.md §3 decision (5)/§10(a)) - this is the diff --git a/MPCAutofill/cardpicker/migrations/0104_cardquestionabstention.py b/MPCAutofill/cardpicker/migrations/0104_cardquestionabstention.py new file mode 100644 index 000000000..9fa1e2a79 --- /dev/null +++ b/MPCAutofill/cardpicker/migrations/0104_cardquestionabstention.py @@ -0,0 +1,39 @@ +# Hand-written (not `manage.py makemigrations`-generated - same convention as migration 0080 +# `questionfeedservedlog.py`) to exactly match `cardpicker.models.CardQuestionAbstention` as of +# this migration. + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cardpicker", "0103_canonicalprintingmetadata_layout"), + ] + + operations = [ + migrations.CreateModel( + name="CardQuestionAbstention", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("anonymous_id", models.CharField(max_length=40)), + ("question_type", models.CharField(max_length=32)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "card", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="question_abstentions", + to="cardpicker.card", + ), + ), + ], + ), + migrations.AddConstraint( + model_name="cardquestionabstention", + constraint=models.UniqueConstraint( + fields=("card", "anonymous_id", "question_type"), name="cardquestionabstention_unique" + ), + ), + ] diff --git a/MPCAutofill/cardpicker/models.py b/MPCAutofill/cardpicker/models.py index 62daf29ee..176ef3fd8 100755 --- a/MPCAutofill/cardpicker/models.py +++ b/MPCAutofill/cardpicker/models.py @@ -2150,6 +2150,16 @@ class CardScanLog(models.Model): call sites) - not a separately-invented vocabulary - so a `grep` for a skip reason in the log output and a `WHERE skip_reason = '...'` query agree on what string to look for. + MACHINE abstention only. `CardQuestionAbstention` (below) is this model's HUMAN counterpart - + issue #712's "Not sure" signal on a `cardpicker.question_feed` item - and is deliberately a + separate model rather than a shared one: this model's `run_id`/`skip_reason`/ + `evidence_types_used`/`survivor_pks` are all calculator-run bookkeeping with no human + equivalent, and the resume-exclusion query this model serves + (`local_identify_printing_tags._eligible_base_queryset`) must never be satisfied by a human + tapping "Not sure" - the two express different facts ("this engine did not vote" vs. "this + person looked and could not tell") and conflating them would let one silently stand in for + the other in either direction. + A card can have at most one CURRENT scan-log row per (card, anonymous_id) that actually matters for the resume-exclusion query (see local_identify_printing_tags._eligible_base_ queryset) - older rows for the same pair are historical (multiple runs can each abstain on @@ -2216,6 +2226,50 @@ def __str__(self) -> str: return f"card={self.card_id} anonymous_id={self.anonymous_id} skip_reason={self.skip_reason}" +class CardQuestionAbstention(models.Model): + """ + Issue #712. Records that a voter ENGAGED with a `cardpicker.question_feed` item and found it + genuinely ambiguous ("Not sure") - real information about the card (this question is hard + to answer for this specific image), unlike a "Skip" tap, which carries no signal about the + card at all and writes nothing here or anywhere else (see QuestionFeed.tsx's `skip`). + + This is a HUMAN abstention - the counterpart to `CardScanLog`'s MACHINE abstention (see that + model's own docstring for why the two are deliberately separate models). Like `CardScanLog`, + deliberately NOT a subclass of `AbstractWeightedVote`: an abstention is not a vote, carries + no source/confidence/user/polarity, and must never be reachable via `vote_consensus`'s + resolution machinery even by accident. + + `question_type` mirrors `QuestionFeedItem.type` (e.g. "confirm_suggestion" / + "identify_printing") - the same free-text convention `QuestionFeedServedLog.question_type` + already uses, for the same reason: the question feed's own type vocabulary is the single + source of truth, and duplicating it as a second closed enum here would just be a second + place for the two to drift apart. + + Unique on (card, anonymous_id, question_type); the write path is `get_or_create`, so a voter + tapping "Not sure" more than once on the same pair (e.g. across repeat serves) records the + fact once, not once per tap. This is also exactly the shape a future exclusion query needs + (issue #713, not built here): "has this anonymous_id already abstained on this card for this + question_type" is a single indexed equality lookup against this table's own unique + constraint, e.g. `CardQuestionAbstention.objects.filter(card_id=..., anonymous_id=..., + question_type=...).exists()`. + """ + + card = models.ForeignKey(to=Card, on_delete=models.CASCADE, related_name="question_abstentions") + anonymous_id = models.CharField(max_length=40) + question_type = models.CharField(max_length=32) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["card", "anonymous_id", "question_type"], name="cardquestionabstention_unique" + ), + ] + + def __str__(self) -> str: + return f"card={self.card_id} anonymous_id={self.anonymous_id} question_type={self.question_type}" + + class SavedDeckKind(models.TextChoices): """ See docs/proposals/proposal-g-user-accounts-saved-decks.md §3/decision 7. DECK rows are @@ -2792,6 +2846,7 @@ def __str__(self) -> str: "ProjectMember", "PilotRunLedger", "CardScanLog", + "CardQuestionAbstention", "SavedDeckKind", "SavedDeck", "UserCryptoProfile", diff --git a/MPCAutofill/cardpicker/schema_types.py b/MPCAutofill/cardpicker/schema_types.py index 9f2848b27..c7f96b369 100644 --- a/MPCAutofill/cardpicker/schema_types.py +++ b/MPCAutofill/cardpicker/schema_types.py @@ -2940,6 +2940,52 @@ def to_dict(self) -> dict: return result +class SubmitQuestionAbstentionRequest(BaseModel): + """ + 2/submitQuestionAbstention/ (issue #712) - hand-maintained, same provenance as + CastImplicitVoteRequest/RetractImplicitVoteRequest above (no JSON schema source exists for + request types in this repo - typed by hand against the real Python pydantic model, not + quicktype-generated). + """ + + anonymousId: str + identifier: str + questionType: str + + @staticmethod + def from_dict(obj: Any) -> "SubmitQuestionAbstentionRequest": + assert isinstance(obj, dict) + anonymousId = from_str(obj.get("anonymousId")) + identifier = from_str(obj.get("identifier")) + questionType = from_str(obj.get("questionType")) + return SubmitQuestionAbstentionRequest(anonymousId, identifier, questionType) + + def to_dict(self) -> dict: + result: dict = {} + result["anonymousId"] = from_str(self.anonymousId) + result["identifier"] = from_str(self.identifier) + result["questionType"] = from_str(self.questionType) + return result + + +class SubmitQuestionAbstentionResponse(BaseModel): + """Mirrors SubmitQuestionAbstentionRequest above - a single-purpose ack, no vote tally to + return since an abstention isn't a vote and never participates in consensus.""" + + recorded: bool + + @staticmethod + def from_dict(obj: Any) -> "SubmitQuestionAbstentionResponse": + assert isinstance(obj, dict) + recorded = from_bool(obj.get("recorded")) + return SubmitQuestionAbstentionResponse(recorded) + + def to_dict(self) -> dict: + result: dict = {} + result["recorded"] = from_bool(self.recorded) + return result + + class ArtistExternalLinksResponse(BaseModel): """ 2/artistExternalLinks/ - hand-maintained, NOT quicktype-generated, same provenance/reasoning diff --git a/MPCAutofill/cardpicker/tests/test_question_abstention.py b/MPCAutofill/cardpicker/tests/test_question_abstention.py new file mode 100644 index 000000000..42db316f1 --- /dev/null +++ b/MPCAutofill/cardpicker/tests/test_question_abstention.py @@ -0,0 +1,107 @@ +import pytest + +from django.core.cache import cache +from django.urls import reverse + +from cardpicker import views +from cardpicker.models import CardQuestionAbstention +from cardpicker.tests.factories import CardFactory + + +@pytest.fixture(autouse=True) +def _clear_rate_limit_cache(): + cache.clear() + yield + cache.clear() + + +class TestPostSubmitQuestionAbstention: + def test_unknown_card_identifier_is_a_bad_request(self, client, django_settings): + response = client.post( + reverse(views.post_submit_question_abstention), + {"identifier": "does-not-exist", "anonymousId": "anon-1", "questionType": "confirm_suggestion"}, + content_type="application/json", + ) + assert response.status_code == 400 + + def test_records_exactly_one_abstention_with_the_right_voter_card_and_question_type(self, client, django_settings): + card = CardFactory() + + response = client.post( + reverse(views.post_submit_question_abstention), + {"identifier": card.identifier, "anonymousId": "anon-1", "questionType": "confirm_suggestion"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["recorded"] is True + assert CardQuestionAbstention.objects.count() == 1 + abstention = CardQuestionAbstention.objects.get() + assert abstention.card_id == card.pk + assert abstention.anonymous_id == "anon-1" + assert abstention.question_type == "confirm_suggestion" + + def test_repeat_taps_from_the_same_voter_record_the_fact_once(self, client, django_settings): + card = CardFactory() + body = {"identifier": card.identifier, "anonymousId": "anon-1", "questionType": "identify_printing"} + + for _ in range(3): + response = client.post( + reverse(views.post_submit_question_abstention), body, content_type="application/json" + ) + assert response.status_code == 200 + + assert CardQuestionAbstention.objects.count() == 1 + + def test_different_question_types_on_the_same_card_and_voter_record_separately(self, client, django_settings): + card = CardFactory() + + for question_type in ("confirm_suggestion", "identify_printing"): + response = client.post( + reverse(views.post_submit_question_abstention), + {"identifier": card.identifier, "anonymousId": "anon-1", "questionType": question_type}, + content_type="application/json", + ) + assert response.status_code == 200 + + assert CardQuestionAbstention.objects.count() == 2 + + def test_different_voters_on_the_same_card_and_question_type_record_separately(self, client, django_settings): + card = CardFactory() + + for anonymous_id in ("anon-1", "anon-2"): + response = client.post( + reverse(views.post_submit_question_abstention), + {"identifier": card.identifier, "anonymousId": anonymous_id, "questionType": "confirm_suggestion"}, + content_type="application/json", + ) + assert response.status_code == 200 + + assert CardQuestionAbstention.objects.count() == 2 + + def test_skip_writes_no_abstention_row(self, db): + # Documents the "Skip" side of the contract at the model layer: nothing in this codebase + # ever calls CardQuestionAbstention.objects.create/get_or_create from a skip path (the + # frontend's `skip` handler never calls `2/submitQuestionAbstention/` at all - see + # QuestionFeed.tsx), so an untouched card simply has zero rows here. + CardFactory() + + assert CardQuestionAbstention.objects.count() == 0 + + def test_abstention_is_queryable_by_voter_card_and_question_type(self, db): + # The shape a future exclusion query (issue #713) needs: "has this anonymous_id already + # abstained on this card for this question_type" as a single indexed equality lookup. + card = CardFactory() + CardQuestionAbstention.objects.get_or_create( + card=card, anonymous_id="anon-1", question_type="confirm_suggestion" + ) + + assert CardQuestionAbstention.objects.filter( + card_id=card.pk, anonymous_id="anon-1", question_type="confirm_suggestion" + ).exists() + assert not CardQuestionAbstention.objects.filter( + card_id=card.pk, anonymous_id="anon-1", question_type="identify_printing" + ).exists() + assert not CardQuestionAbstention.objects.filter( + card_id=card.pk, anonymous_id="anon-2", question_type="confirm_suggestion" + ).exists() diff --git a/MPCAutofill/cardpicker/urls.py b/MPCAutofill/cardpicker/urls.py index abe16cde6..cc3538176 100755 --- a/MPCAutofill/cardpicker/urls.py +++ b/MPCAutofill/cardpicker/urls.py @@ -25,6 +25,7 @@ path("2/printingCandidates/", views.post_printing_candidates), path("2/printingConsensus/", views.post_printing_consensus), path("2/submitPrintingTag/", views.post_submit_printing_tag), + path("2/submitQuestionAbstention/", views.post_submit_question_abstention), path("2/printingTagQueue/", views.get_printing_tag_queue), path("2/artistCandidates/", views.post_artist_candidates), path("2/artistConsensus/", views.post_artist_consensus), diff --git a/MPCAutofill/cardpicker/views.py b/MPCAutofill/cardpicker/views.py index a2282d419..0e9abccaf 100644 --- a/MPCAutofill/cardpicker/views.py +++ b/MPCAutofill/cardpicker/views.py @@ -73,6 +73,7 @@ Card, CardArtistVote, CardPrintingTag, + CardQuestionAbstention, CardReport, CardScanLog, CardTagVote, @@ -218,6 +219,8 @@ SubmitIllustrationVoteRequest, SubmitIllustrationVoteResponse, SubmitPrintingTagRequest, + SubmitQuestionAbstentionRequest, + SubmitQuestionAbstentionResponse, SubmitTagVoteRequest, TagConsensusEntry, TagConsensusRequest, @@ -1114,6 +1117,36 @@ def post_submit_printing_tag(request: HttpRequest) -> HttpResponse: return JsonResponse(_build_printing_consensus_response(card, resolved).model_dump()) +@ratelimit( # type: ignore # `django-ratelimit` does not implement decorator typing correctly + key=_printing_tag_rate_limit_key, rate=_printing_tag_rate_limit_rate, method="POST", block=False +) +@ErrorWrappers.to_json +def post_submit_question_abstention(request: HttpRequest) -> HttpResponse: + """ + Issue #712. Records a human abstention ("Not sure") on a question-feed item - the counterpart + to `CardScanLog`'s machine abstention, see `CardQuestionAbstention`'s own docstring for why + the two are separate models. `get_or_create` makes repeat taps on the same (card, + anonymousId, questionType) idempotent. Reuses the printing-tag submission's rate-limit + plumbing (`_printing_tag_rate_limit_key`/`_printing_tag_rate_limit_rate`), the same rate + budget every other question-feed write already shares, not a separate budget. + """ + + if request.method != "POST": + raise BadRequestException("Expected POST request.") + if getattr(request, "limited", False): + return JsonResponse( + ErrorResponse(name="Rate limited", message="Too many abstentions - please slow down.").model_dump(), + status=429, + ) + + req = SubmitQuestionAbstentionRequest.model_validate(json.loads(request.body)) + card = _get_card_or_400(req.identifier) + CardQuestionAbstention.objects.get_or_create( + card=card, anonymous_id=req.anonymousId, question_type=req.questionType + ) + return JsonResponse(SubmitQuestionAbstentionResponse(recorded=True).model_dump()) + + def _build_artist_consensus_response( card: Card, resolved: CanonicalArtist | Literal["UNKNOWN"] | None ) -> ArtistConsensusResponse: diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index f6723e8a7..4b47857d7 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -727,6 +727,23 @@ printings, artists, tags, and moderation from one screen. `question_feed._served_mix_ratio` reads it back as two cheap indexed `COUNT`s, never a per-row scan. Append-only, same convention as `CardScanLog` — never read by any consensus computation. +- **"Not sure" abstention** (issue #712): Level 1's "Yes"/"No, different + printing" both cast a real vote (see `selectCandidate`/`rejectSuggestion` + in `QuestionFeed.tsx`); "Not sure" and "Skip" used to be indistinguishable + no-ops — neither wrote anything. They are now split: "Not sure" means the + voter engaged and found the image genuinely ambiguous, real information + about the CARD, so it POSTs `2/submitQuestionAbstention/` and is recorded + in `CardQuestionAbstention` (`card`, `anonymous_id`, `question_type`, + unique together, `get_or_create`-idempotent per repeat tap) before the + same `setStage("level2")` transition it always did. "Skip" carries no + signal about the card at all and still writes nothing anywhere — that + stays a deliberate no-op, not a bug. `CardQuestionAbstention` is the + HUMAN counterpart to `CardScanLog`'s MACHINE abstention (see that + model's own docstring for why they're separate tables) and is, like it, + NOT an `AbstractWeightedVote` subclass — an abstention never enters + `vote_consensus`. A future exclusion query (issue #713, not built by + this addition) reads it back as a single indexed equality lookup: + `CardQuestionAbstention.objects.filter(card_id=..., anonymous_id=..., question_type=...).exists()`. - **Remaining-work count**: `get_remaining_estimate()` returns `QuestionFeedCounts` (`schemas/schemas/QuestionFeedCounts.json`), not a single number. `total` is a `.distinct().count()` union across diff --git a/frontend/src/common/schema_types.ts b/frontend/src/common/schema_types.ts index 924a3640d..7a71ee775 100644 --- a/frontend/src/common/schema_types.ts +++ b/frontend/src/common/schema_types.ts @@ -1090,6 +1090,22 @@ export interface RetractImplicitVoteRequest { tagName: string; } +/** + * 2/submitQuestionAbstention/ (issue #712) - hand-maintained, mirroring + * MPCAutofill/cardpicker/schema_types.py's SubmitQuestionAbstentionRequest (same provenance as + * CastImplicitVoteRequest above - no JSON schema source exists for request types in this repo). + */ +export interface SubmitQuestionAbstentionRequest { + anonymousId: string; + identifier: string; + questionType: string; +} + +/** 2/submitQuestionAbstention/ response - mirrors SubmitQuestionAbstentionResponse. */ +export interface SubmitQuestionAbstentionResponse { + recorded: boolean; +} + /** * One external link for an artist (MTG Artist Connection integration - see * MPCAutofill/cardpicker/artist_external_links.py's module docstring). `type` is one of the diff --git a/frontend/src/features/questionFeed/QuestionFeed.test.tsx b/frontend/src/features/questionFeed/QuestionFeed.test.tsx index 9cd937cc3..6d71e9682 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.test.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.test.tsx @@ -944,4 +944,78 @@ describe("QuestionFeed", () => { ); }); }); + + // Issue #712 - "Not sure" and "Skip" used to be indistinguishable no-ops; this locks in the + // split: "Not sure" records an abstention, "Skip" still writes nothing. + describe("Not sure vs. Skip (issue #712)", () => { + const confirmSuggestionItem = { + ...identifyPrintingItem, + type: "confirm_suggestion", + suggestedPrinting: identifyPrintingItem.candidates[0], + }; + + function serveConfirmSuggestionOnce() { + return http.get(buildRoute("2/questionFeed/"), () => + HttpResponse.json( + { + item: confirmSuggestionItem, + remainingEstimate: { + total: 1, + confirmable: 1, + contested: 0, + fresh: 0, + }, + }, + { status: 200 } + ) + ); + } + + it("tapping Level 1 'Not sure' POSTs an abstention for this card and question type, then advances to Level 2", async () => { + server.use(serveConfirmSuggestionOnce()); + let abstentionBody: Record | undefined; + server.use( + http.post( + buildRoute("2/submitQuestionAbstention/"), + async ({ request }) => { + abstentionBody = (await request.json()) as Record; + return HttpResponse.json({ recorded: true }, { status: 200 }); + } + ) + ); + renderFeed(); + await revealCard(); + + fireEvent.click( + await screen.findByTestId("question-feed-level1-not-sure") + ); + + await waitFor(() => expect(abstentionBody).toBeDefined()); + expect(abstentionBody).toMatchObject({ + identifier: confirmSuggestionItem.card.identifier, + questionType: "confirm_suggestion", + }); + expect( + await screen.findByTestId("question-feed-level2") + ).toBeInTheDocument(); + }); + + it("tapping Level 1 'Skip' never calls submitQuestionAbstention", async () => { + server.use(serveConfirmSuggestionOnce()); + let abstentionCalls = 0; + server.use( + http.post(buildRoute("2/submitQuestionAbstention/"), () => { + abstentionCalls += 1; + return HttpResponse.json({ recorded: true }, { status: 200 }); + }) + ); + renderFeed(); + await revealCard(); + + fireEvent.click(await screen.findByTestId("question-feed-level1-skip")); + await revealCard(); + + expect(abstentionCalls).toBe(0); + }); + }); }); diff --git a/frontend/src/features/questionFeed/QuestionFeed.tsx b/frontend/src/features/questionFeed/QuestionFeed.tsx index 8e3a37340..aab98187c 100644 --- a/frontend/src/features/questionFeed/QuestionFeed.tsx +++ b/frontend/src/features/questionFeed/QuestionFeed.tsx @@ -90,6 +90,7 @@ import { APIGetQuestionFeed, APISubmitIllustrationVote, APISubmitPrintingTag, + APISubmitQuestionAbstention, APISubmitTagVote, } from "@/store/api"; import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; @@ -1046,6 +1047,21 @@ export function QuestionFeed() { const skip = () => advance(); + // Records the "Not sure" abstention (issue #712) and moves on - fire-and-forget, same + // best-effort convention as the auto-tag-chip casts in selectCandidate above: the write is + // informative, not gating, so a failed request never blocks the stage transition. + const submitNotSure = () => { + if (backendURL != null && item != null) { + APISubmitQuestionAbstention( + backendURL, + item.card.identifier, + getOrCreateAnonymousId(), + item.type + ).catch(() => undefined); + } + setStage("level2"); + }; + // Level 1's NO. In the general case this casts no vote itself - there's no backend concept of // "reject just this one candidate specifically," only a positive vote for a specific printing // or a generic isNoMatch for the whole set (see selectCandidate above) - so it purely records @@ -1342,7 +1358,7 @@ export function QuestionFeed() { setStage("level2")} + onClick={submitNotSure} data-testid="question-feed-level1-not-sure" > Not sure diff --git a/frontend/src/store/api.ts b/frontend/src/store/api.ts index 3ae00a52a..1fdc06ba1 100644 --- a/frontend/src/store/api.ts +++ b/frontend/src/store/api.ts @@ -696,6 +696,36 @@ export async function APISubmitPrintingTag( }); } +// Issue #712. Records a human "Not sure" abstention on a question-feed item - distinct from a +// "Skip" tap, which never calls this or any other endpoint. Best-effort at the call site (see +// QuestionFeed.tsx's submitNotSure): never blocks the Level 1 -> Level 2 transition it accompanies. +export async function APISubmitQuestionAbstention( + backendURL: string, + identifier: string, + anonymousId: string, + questionType: string +): Promise { + const rawResponse = await fetch( + formatURL(backendURL, "/2/submitQuestionAbstention/"), + { + method: "POST", + body: JSON.stringify({ identifier, anonymousId, questionType }), + credentials: "same-origin", + headers: getCSRFHeader(), + } + ); + return rawResponse.json().then((content) => { + if (rawResponse.status === 200 && content.recorded != null) { + return content as SubmitQuestionAbstentionResponse; + } + throw { + name: content.name, + message: content.message, + status: rawResponse.status, + }; + }); +} + // Issue #503 (WTC phase C2) / #524. Sends ONE illustrationId (or isUnknown) for a card - NEVER // a printing list, since a shared-illustration group's actual printing count can only be known // server-side, at write time, against live data (see MPCAutofill/cardpicker/illustration_vote.py From 7c30d5e85137f3e5106475daebe0fbc2702c885c Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Thu, 6 Aug 2026 02:40:17 +0000 Subject: [PATCH 2/2] store/api.ts: import the missing SubmitQuestionAbstentionResponse type APISubmitQuestionAbstention's return type annotation referenced SubmitQuestionAbstentionResponse without importing it from schema_types - the type is exported there, just never pulled into api.ts's import list. Jest's transpile-only transform doesn't type-check, so this only surfaced as a 'next build' compile failure (the build step that only runs on shard 1 of the sharded Playwright job, alongside Jest), not a test failure. Also adds a mocks/handlers.ts handler for the new /2/submitQuestionAbstention/ endpoint (previously unhandled in Playwright's MSW fixture - requests to it silently fell through to the real network) and wires it into the Level 1 NOT SURE Playwright spec, extending that test's existing 'no printing vote cast' assertion with a payload check on the new abstention POST. --- .github/coverage-acks.txt | 7 +++++++ frontend/src/mocks/handlers.ts | 5 +++++ frontend/src/store/api.ts | 1 + frontend/tests/QuestionFeed.spec.ts | 18 ++++++++++++++++-- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/coverage-acks.txt b/.github/coverage-acks.txt index d2046fce1..fd56a297c 100644 --- a/.github/coverage-acks.txt +++ b/.github/coverage-acks.txt @@ -127,3 +127,10 @@ coverage-ack: frontend/tests/DisplayPage.spec.ts::DisplayPage (Proposal H, Step # Same surface, same assertions, only the URL (and the describe/test title naming it) changed. coverage-ack: frontend/tests/ContrastAudit.spec.ts::Contrast audit - /contributions (owner defect 1+2: accordion header + body) > Contribution Guidelines accordion - collapsed and expanded — route moved /contributions -> /stats in PR #558; same surface, same assertions coverage-ack: frontend/tests/ContrastAudit.spec.ts::Link colour audit (owner-approved open item 2 - $link-color -> $theme-info) > a real production link (contributions guidelines' ISO-639-1 reference, inside the accordion body's panel-bg) clears strict-AAA-normal — route moved /contributions -> /stats in PR #558; same surface, same assertions + +# PR #731 (issue #712) - Level 1 "Not sure" now fires a fire-and-forget POST to the new +# /2/submitQuestionAbstention/ endpoint before transitioning to Level 2, in addition to the +# unchanged "no printing vote cast" behavior this test already covered. Renamed (not deleted) to +# describe both halves of the contract, and extended with a mocked handler + payload assertion +# for the new POST - same surface (the NOT SURE button), same "no printing vote" claim kept. +coverage-ack: frontend/tests/QuestionFeed.spec.ts::question feed - confirm_suggestion question type > NOT SURE drops to Level 2's candidate grid without casting a vote — PR #731 (issue #712): renamed to "NOT SURE drops to Level 2's candidate grid without casting a printing vote, but does POST an abstention" now that NOT SURE fires a fire-and-forget POST to the new /2/submitQuestionAbstention/ endpoint; the "no printing vote" assertion is kept, extended with coverage of the new abstention POST. diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index eeafb3c5b..678dddc85 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1463,6 +1463,11 @@ export const submitPrintingTagResolvesToPrintingCandidate2 = http.post( ) ); +export const submitQuestionAbstentionRecorded = http.post( + buildRoute("2/submitQuestionAbstention/"), + () => HttpResponse.json({ recorded: true }, { status: 200 }) +); + export const submitPrintingTagNoMatch = http.post( buildRoute("2/submitPrintingTag/"), () => diff --git a/frontend/src/store/api.ts b/frontend/src/store/api.ts index 1fdc06ba1..02583324a 100644 --- a/frontend/src/store/api.ts +++ b/frontend/src/store/api.ts @@ -79,6 +79,7 @@ import { SaveDeckResponse, SourcesResponse, SubmitIllustrationVoteResponse, + SubmitQuestionAbstentionResponse, Tag, TagConsensusResponse, TagsResponse, diff --git a/frontend/tests/QuestionFeed.spec.ts b/frontend/tests/QuestionFeed.spec.ts index 709941dd5..75869ec01 100644 --- a/frontend/tests/QuestionFeed.spec.ts +++ b/frontend/tests/QuestionFeed.spec.ts @@ -29,6 +29,7 @@ import { submitPrintingTagNoMatch, submitPrintingTagResolvesToPrintingCandidate1, submitPrintingTagResolvesToPrintingCandidate2, + submitQuestionAbstentionRecorded, submitTagVoteResolvesToApply, } from "@/mocks/handlers"; @@ -475,16 +476,24 @@ test.describe("question feed - confirm_suggestion question type", () => { .toBe(printingCandidate1.identifier); }); - test("NOT SURE drops to Level 2's candidate grid without casting a vote", async ({ + test("NOT SURE drops to Level 2's candidate grid without casting a printing vote, but does POST an abstention", async ({ page, network, }) => { let printingTagSubmitted = false; - network.use(questionFeedConfirmSuggestion, ...defaultHandlers); + let abstentionBody: { identifier?: string; questionType?: string } = {}; + network.use( + questionFeedConfirmSuggestion, + submitQuestionAbstentionRecorded, + ...defaultHandlers + ); page.on("request", (request) => { if (request.url().includes("/2/submitPrintingTag/")) { printingTagSubmitted = true; } + if (request.url().includes("/2/submitQuestionAbstention/")) { + abstentionBody = request.postDataJSON(); + } }); await loadPageWithDefaultBackend(page, "whatsthat"); @@ -496,6 +505,11 @@ test.describe("question feed - confirm_suggestion question type", () => { await expect(suggestedCandidate).toBeVisible(); await expect(suggestedCandidate).toHaveClass(/highlighted/); expect(printingTagSubmitted).toBe(false); + + await expect + .poll(() => abstentionBody.identifier) + .toBe(cardDocument1.identifier); + expect(abstentionBody.questionType).toBe("confirm_suggestion"); }); test("NO drops to Level 2's candidate grid, excluding the rejected suggestion, without casting a vote", async ({