Skip to content

Commit

Permalink
Added "fuzzy lookup" endpoint for cards
Browse files Browse the repository at this point in the history
Intended for use with a Discord bot.
  • Loading branch information
onecrayon committed Mar 25, 2024
1 parent 6933f71 commit 6c928eb
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 11 deletions.
4 changes: 4 additions & 0 deletions api/tests/cards/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down
48 changes: 37 additions & 11 deletions api/tests/cards/test_card_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"
74 changes: 74 additions & 0 deletions api/views/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 6c928eb

Please sign in to comment.