From 6c928eb2903ceb174c7d853c9c29f34577007582 Mon Sep 17 00:00:00 2001 From: Ian Beck Date: Mon, 25 Mar 2024 13:58:41 -0700 Subject: [PATCH] Added "fuzzy lookup" endpoint for cards Intended for use with a Discord bot. --- api/tests/cards/conftest.py | 4 ++ api/tests/cards/test_card_read.py | 48 +++++++++++++++----- api/views/cards.py | 74 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/api/tests/cards/conftest.py b/api/tests/cards/conftest.py index 3c4c1f9..043aa1a 100644 --- a/api/tests/cards/conftest.py +++ b/api/tests/cards/conftest.py @@ -127,6 +127,10 @@ def _create_cards_for_filtration(session: db.Session, is_legacy=False): if is_legacy: for card in cards: card.is_legacy = True + # This is normally handled by a migration, since legacy cards can't be added + card.json["release"]["is_legacy"] = True + card.json["is_legacy"] = True + db.flag_modified(card, "json") session.commit() diff --git a/api/tests/cards/test_card_read.py b/api/tests/cards/test_card_read.py index e3219c8..3740a14 100644 --- a/api/tests/cards/test_card_read.py +++ b/api/tests/cards/test_card_read.py @@ -7,18 +7,8 @@ from api.tests.utils import create_user_token -def test_get_legacy_card(client: TestClient, session: db.Session): +def test_get_legacy_card(client: TestClient): """Must be able to read JSON for a legacy card""" - # This is handled by a migration normally (legacy cards can't normally be created by this API) - card = ( - session.query(Card) - .filter(Card.stub == "example-phoenixborn", Card.is_legacy == True) - .first() - ) - card.json["release"]["is_legacy"] = True - card.json["is_legacy"] = True - db.flag_modified(card, "json") - session.commit() response = client.get("/v2/cards/example-phoenixborn", params={"show_legacy": True}) assert response.status_code == status.HTTP_200_OK assert response.json()["is_legacy"] == True, response.json() @@ -104,3 +94,39 @@ def test_get_details_last_seen_entity_id(client: TestClient, session: db.Session assert response.status_code == status.HTTP_200_OK data = response.json() assert data["last_seen_entity_id"] == comment.entity_id + + +def test_get_card_fuzzy_lookup_required_query(client: TestClient): + """Must require querystring""" + response = client.get("/v2/cards/fuzzy-lookup") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + response = client.get("/v2/cards/fuzzy-lookup?q=%20%20") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +def test_get_card_fuzzy_lookup_legacy(client: TestClient): + """Must fetch legacy cards properly""" + response = client.get("/v2/cards/fuzzy-lookup?q=action&show_legacy=true") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["is_legacy"] is True, data + + +def test_get_card_fuzzy_lookup_bad_query(client: TestClient): + """Must throw appropriate error when search returns no results""" + response = client.get("/v2/cards/fuzzy-lookup?q=nada") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_card_fuzzy_lookup_exact_stub(client: TestClient): + """Must correctly select when the stub is exact""" + response = client.get("/v2/cards/fuzzy-lookup?q=Example%20Conjuration") + assert response.status_code == status.HTTP_200_OK + assert response.json()["stub"] == "example-conjuration" + + +def test_get_fuzzy_lookup_summon_stub(client: TestClient): + response = client.get("/v2/cards/fuzzy-lookup?q=Summon%20Example") + assert response.status_code == status.HTTP_200_OK + assert response.json()["stub"] == "summon-example-conjuration" diff --git a/api/views/cards.py b/api/views/cards.py index c33412b..e6a3d62 100644 --- a/api/views/cards.py +++ b/api/views/cards.py @@ -265,6 +265,80 @@ def list_cards( ) +@router.get( + "/cards/fuzzy-lookup", + response_model=CardOut, + response_model_exclude_unset=True, + responses={404: {"model": DetailResponse}} +) +def get_card_fuzzy_lookup( + q: str, show_legacy: bool = False, session: db.Session = Depends(get_session) +): + """Returns a single card using fuzzy lookup logic + + This is similar to querying `/cards` limited to a single result, except that it applies the + following heuristics when searching for cards: + + * Preference the card with the search term in its stub + * Preference the card without "summon" in its stub if "summon" is not in the query + * Preference the card with "summon" in its stub if "summon" is in the query + """ + # Make sure we have a search term + if not q or not q.strip(): + raise APIException(detail="Query string is required.") + query = session.query(Card).join(Card.release).filter(Release.is_public.is_(True)) + if show_legacy: + query = query.filter(Card.is_legacy.is_(True)) + else: + query = query.filter(Card.is_legacy.is_(False)) + stub_search = stubify(q) + search_vector = db.func.to_tsvector("english", Card.search_text) + prefixed_query = to_prefixed_tsquery(q) + query = query.filter( + db.or_( + search_vector.match( + prefixed_query + ), + Card.stub.like(f"%{stub_search}%"), + ) + ) + # Order by search ranking + possible_cards = query.order_by(Card.name.asc()).all() + if not possible_cards: + raise NotFoundException(detail="No matching cards found.") + ranks_with_matches = [] + # We use this to calculate boost offsets for our three conditions (exact stub match, + # partial stub match, "summon") + base_upper_rank = len(possible_cards) + # We use this to track the original Postgres alphabetical ordering (our fallback). I originally + # tested this using the full text ranking, and it was incredibly opaque, so tossed that. + db_rank = base_upper_rank + for card in possible_cards: + rank = db_rank + # First check for exact stub matches, and give those greatest preference + if ( + card.stub == stub_search + or card.stub.startswith(f"{stub_search}-") + or card.stub.endswith(f"-{stub_search}") + or f"-{stub_search}-" in card.stub + ): + rank += (base_upper_rank * 3) + elif stub_search in card.stub: + # We have some level of partial stub match, so give that a big preference boost + rank += (base_upper_rank * 2) + # And then boost things based on whether "summon" exists (or does not) in both terms + if ( + ("summon" in stub_search and "summon" in card.stub) + or ("summon" not in stub_search and "summon" not in card.stub) + ): + rank += (base_upper_rank + 1) + ranks_with_matches.append((rank, card)) + db_rank -= 1 + # Sort our cards in descending rank order, then return the JSON from the first result + ranks_with_matches.sort(key=lambda x: x[0], reverse=True) + return ranks_with_matches[0][1].json + + @router.get( "/cards/{stub}", response_model=CardOut,