Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(favorites): modifying pray-and-pay ranking algorithm #5186

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
90 changes: 33 additions & 57 deletions cl/favorites/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,96 +798,72 @@ async def test_get_top_prayers_by_number(self) -> None:
msg="Wrong top_prayers based on prayers count.",
)

async def test_get_top_prayers_by_age(self) -> None:
async def test_get_top_prayers_by_views(self) -> None:
"""Does the get_top_prayers method work properly?"""

# Test top documents based on prayer age.
current_time = now()
with time_machine.travel(
current_time - timedelta(minutes=1), tick=False
):
await create_prayer(self.user, self.rd_4)
# Test top documents based on docket views.
self.rd_2.docket_entry.docket.view_count = 4
self.rd_3.docket_entry.docket.view_count = 12
self.rd_4.docket_entry.docket.view_count = 6

with time_machine.travel(
current_time - timedelta(minutes=2), tick=False
):
await create_prayer(self.user, self.rd_2)
self.rd_2.docket_entry.docket.asave()
self.rd_3.docket_entry.docket.asave()
self.rd_4.docket_entry.docket.asave()

with time_machine.travel(
current_time - timedelta(minutes=3), tick=False
):
await create_prayer(self.user_2, self.rd_3)
await create_prayer(self.user, self.rd_4)
await create_prayer(self.user, self.rd_2)
await create_prayer(self.user_2, self.rd_3)

top_prayers = await get_top_prayers()
self.assertEqual(await top_prayers.acount(), 3)
expected_top_prayers = [self.rd_3.pk, self.rd_2.pk, self.rd_4.pk]
expected_top_prayers = [self.rd_3.pk, self.rd_4.pk, self.rd_2.pk]
actual_top_prayers = [top_rd.pk async for top_rd in top_prayers]

self.assertEqual(
actual_top_prayers,
expected_top_prayers,
msg="Wrong top_prayers based on prayers age.",
msg="Wrong top_prayers based on docket view count.",
)

async def test_get_top_prayers_by_number_and_age(self) -> None:
async def test_get_top_prayers_by_number_and_views(self) -> None:
"""Does the get_top_prayers method work properly?"""

# Create prayers with different counts and ages
current_time = now()
with time_machine.travel(current_time - timedelta(days=5), tick=False):
await create_prayer(self.user, self.rd_5) # 1 prayer, 5 days old
self.rd_2.docket_entry.docket.view_count = 4
self.rd_3.docket_entry.docket.view_count = 1
self.rd_4.docket_entry.docket.view_count = 6
self.rd_5.docket_entry.docket.view_count = 8

with time_machine.travel(current_time - timedelta(days=3), tick=False):
await create_prayer(self.user, self.rd_2)
await create_prayer(
self.user_2, self.rd_2
) # 2 prayers, 3 days old
self.rd_2.docket_entry.docket.asave()
self.rd_3.docket_entry.docket.asave()
self.rd_4.docket_entry.docket.asave()
self.rd_5.docket_entry.docket.asave()

with time_machine.travel(current_time - timedelta(days=1), tick=False):
await create_prayer(self.user, self.rd_3)
await create_prayer(self.user_2, self.rd_3)
await create_prayer(self.user_3, self.rd_3) # 3 prayers, 1 day old
# Create prayers with different counts and views

with time_machine.travel(current_time - timedelta(days=4), tick=False):
await create_prayer(self.user, self.rd_4)
await create_prayer(
self.user_2, self.rd_4
) # 2 prayers, 4 days old
await create_prayer(self.user, self.rd_5)
await create_prayer(self.user, self.rd_2)
await create_prayer(self.user_2, self.rd_2)
await create_prayer(self.user, self.rd_3)
await create_prayer(self.user_2, self.rd_3)
await create_prayer(self.user_3, self.rd_3)
await create_prayer(self.user, self.rd_4)
await create_prayer(self.user_2, self.rd_4)

top_prayers = await get_top_prayers()
self.assertEqual(await top_prayers.acount(), 4)

expected_top_prayers = [
self.rd_3.pk,
self.rd_4.pk,
self.rd_2.pk,
self.rd_5.pk,
self.rd_3.pk,
]
actual_top_prayers = [top_rd.pk async for top_rd in top_prayers]

self.assertEqual(
actual_top_prayers,
expected_top_prayers,
msg="Wrong top_prayers based on combined prayer count and age.",
)

# Compute expected geometric means
rd_4_score = math.sqrt(2 * (4 * 3600 * 24))
rd_2_score = math.sqrt(2 * (3 * 3600 * 24))
rd_5_score = math.sqrt(1 * (5 * 3600 * 24))
rd_3_score = math.sqrt(3 * (1 * 3600 * 24))

self.assertAlmostEqual(
top_prayers[0].geometric_mean, rd_4_score, places=2
)
self.assertAlmostEqual(
top_prayers[1].geometric_mean, rd_2_score, places=2
)
self.assertAlmostEqual(
top_prayers[2].geometric_mean, rd_5_score, places=2
)
self.assertAlmostEqual(
top_prayers[3].geometric_mean, rd_3_score, places=2
msg="Wrong top_prayers based on combined prayer count and docket view count.",
)

async def test_get_user_prayers(self) -> None:
Expand Down
27 changes: 9 additions & 18 deletions cl/favorites/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ async def get_existing_prayers_in_bulk(


async def get_top_prayers() -> QuerySet[RECAPDocument]:
# Calculate the age of each prayer
prayer_age = ExpressionWrapper(
Extract(Now() - F("prayers__date_created"), "epoch"),
output_field=FloatField(),
)
"""Retrieve the most desired documents that have open prayers. It first
ranks by the number of requests and then by the number of views the particular
docket has received.

:return: A queryset of RECAPDocuments in descending order of preference.
"""

waiting_prayers = Prayer.objects.filter(status=Prayer.WAITING).values(
"recap_document_id"
)
Expand Down Expand Up @@ -144,20 +146,9 @@ async def get_top_prayers() -> QuerySet[RECAPDocument]:
prayer_count=Count(
"prayers", filter=Q(prayers__status=Prayer.WAITING)
),
avg_prayer_age=Avg(
prayer_age, filter=Q(prayers__status=Prayer.WAITING)
),
)
.annotate(
geometric_mean=Sqrt(
Cast(
F("prayer_count")
* Cast(F("avg_prayer_age"), FloatField()),
FloatField(),
)
)
view_count=F("docket_entry__docket__view_count"),
)
.order_by("-geometric_mean")
.order_by("-prayer_count", "-view_count")
)

return documents
Expand Down
1 change: 1 addition & 0 deletions cl/search/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ class Meta:
docket_number = Faker("federal_district_docket_number")
slug = Faker("slug")
date_argued = Faker("date_object")
view_count = 0

"""
This hook is necessary to make this factory compatible with the
Expand Down
2 changes: 1 addition & 1 deletion cl/simple_pages/templates/help/prayer_help.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ <h2 id="pray-and-pay">Pray and Pay Project</h2>


<h3 id="fulfilling-prayer">Fulfilling a Prayer</h3>
<p>The most-wanted documents are shown on a <a href="{% url "top_prayers" %}">leaderboard</a> ranked by how many users have requested a particular document and how old the requests are. As requests are fulfilled, they are removed from the leaderboard.
<p>The most-wanted documents are shown on a <a href="{% url "top_prayers" %}">leaderboard</a> ranked by how many users have requested a particular document and how many views their corresponding docket has received. As requests are fulfilled, they are removed from the leaderboard.
</p>
<p>Fulfilling somebody's prayer only works if you install the RECAP extension. Once it's installed, all your PACER purchases will be sent to CourtListener automatically — Prayers will be granted!</p>
<p>
Expand Down
Loading