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
73 changes: 68 additions & 5 deletions products/signals/backend/personal_ranking/list_sort.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""Read one person's stored list for `sort=personal` on the report list. The read never builds.
"""Read one person's stored list for the report list. The reads never build.

It reads one `PersonalInboxRanking` row and nothing else: no candidate pools, no queued build, no model call. So a
page load can use it. The order puts the ranked reports first by stored rank, then the folded reports, and the view
orders every other report in the person's `recommended` order. With no usable row there is no order, and the list is
the plain `recommended` order.
Each read takes one `PersonalInboxRanking` row and nothing else: no candidate pools, no queued build, no model call.
So a page load can use them. For `sort=personal`, the order puts the ranked reports first by stored rank, then the
folded reports, and the view orders every other report in the person's `recommended` order. With no usable row there
is no order, and the list is the plain `recommended` order. For every sort, the status says what the person's latest
row holds, so the client can record whether a personal ranking was ready.
"""

import uuid
import datetime
from collections.abc import Mapping
from typing import Literal

from posthog.dataclasses import frozen
from posthog.models import Team, User

from products.signals.backend.models import PersonalInboxRanking
from products.signals.backend.personal_ranking.config import read_config

PersonalRankingState = Literal["real", "one_report", "not_personalized", "empty", "fallback", "none"]


@frozen
class PersonalRowNote:
Expand Down Expand Up @@ -63,3 +68,61 @@ def personal_list_order(team: Team, user: User, ranking_id: str | None = None) -
if not report_ids:
return None
return PersonalListOrder(ranking_id=str(row.id), report_ids=tuple(report_ids), notes=notes)


@frozen
class PersonalRankingStatus:
state: PersonalRankingState
# The ranked and folded reports of the row. None when there is no row.
size: int | None
age_minutes: int | None
# Minutes since a build or the scheduled tick last confirmed the row's inputs.
checked_age_minutes: int | None
fallback_reason: str | None


NO_PERSONAL_RANKING = PersonalRankingStatus(
state="none", size=None, age_minutes=None, checked_age_minutes=None, fallback_reason=None
)


def personal_ranking_state(status: str, personalized: bool, size: int) -> PersonalRankingState:
"""Only an ok, personalized row of 2 or more reports can change the order of the list."""
if status == PersonalInboxRanking.Status.EMPTY:
return "empty"
if status != PersonalInboxRanking.Status.OK:
return "fallback"
if not personalized:
return "not_personalized"
if size == 0:
return "empty"
return "real" if size >= 2 else "one_report"


def _minutes_since(moment: datetime.datetime, now: datetime.datetime) -> int:
return max(int((now - moment).total_seconds() // 60), 0)


def personal_ranking_status(team: Team, user: User, now: datetime.datetime) -> PersonalRankingStatus | None:
"""What the person's latest stored row holds, with one indexed query. None when personal ranking is off."""
if not read_config().enabled:
return None
row = (
PersonalInboxRanking.objects.for_team(team.id)
.filter(user_id=user.id)
.order_by("-created_at", "-id")
.only("created_at", "checked_at", "status", "personalized", "fallback_reason", "result")
.first()
)
if row is None:
return NO_PERSONAL_RANKING
result = row.result or {}
size = len(result.get("ranked") or ()) + len(result.get("folded") or ())
state = personal_ranking_state(row.status, row.personalized, size)
return PersonalRankingStatus(
state=state,
size=size,
age_minutes=_minutes_since(row.created_at, now),
checked_age_minutes=_minutes_since(row.checked_at or row.created_at, now),
fallback_reason=row.fallback_reason if state == "fallback" else None,
)
81 changes: 79 additions & 2 deletions products/signals/backend/test/test_personal_ranking.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

from django.core.cache import cache
from django.core.management import call_command
from django.db import connection
from django.db.models import F
from django.test import SimpleTestCase
from django.test.utils import CaptureQueriesContext
from django.utils import timezone

import httpx
Expand Down Expand Up @@ -45,6 +47,7 @@
ReasonLimits,
parse_config,
)
from products.signals.backend.personal_ranking.list_sort import personal_ranking_state
from products.signals.backend.personal_ranking.logic import (
BUILT_EVENT,
InputChanges,
Expand Down Expand Up @@ -1292,6 +1295,23 @@ def test_unavailable(self, _name: str, payload: dict[str, Any], flag_enabled: bo
assert (self._rows(), self.queued) == (0, [])


class TestPersonalRankingState(SimpleTestCase):
@parameterized.expand(
[
("real", "ok", True, 2, "real"),
("one_report", "ok", True, 1, "one_report"),
("not_personalized", "ok", False, 3, "not_personalized"),
("ok_without_reports", "ok", True, 0, "empty"),
("empty", "empty", False, 0, "empty"),
("fallback", "fallback", False, 3, "fallback"),
]
)
def test_only_an_ok_personalized_row_of_two_reports_is_real(
self, _name: str, status: str, personalized: bool, size: int, expected: str
) -> None:
assert personal_ranking_state(status, personalized, size) == expected


class TestPersonalListSort(APIBaseTest):
def setUp(self) -> None:
super().setUp()
Expand Down Expand Up @@ -1350,8 +1370,7 @@ def _ranking(self, ranked: list[SignalReport], folded: list[SignalReport], **kwa
input_hash="h",
model="m",
prompt_version=1,
status="ok",
**{"user": self.user, **defaults, **kwargs},
**{"user": self.user, "status": "ok", **defaults, **kwargs},
)

def _variants(self, variants: dict[str, str]) -> None:
Expand Down Expand Up @@ -1505,3 +1524,61 @@ def test_recommended_exposes_the_personal_test_only_with_a_full_list(
for call in self.analytics.get_feature_flag.call_args_list
if call.args[0] == PERSONAL_RECOMMENDED_FLAG
] == exposures

@parameterized.expand([("priority", {}), ("personal", {}), ("personal", {"scope": "teammate"})])
def test_first_page_reports_the_viewer_ranking_status(self, sort: str, params: dict[str, Any]) -> None:
r0, r1, r2, *_ = self.reports
teammate = User.objects.create(email="teammate@example.com")
OrganizationMembership.objects.create(user=teammate, organization=self.organization)
with time_machine.travel("2026-03-01T12:00:00Z", tick=False):
row = self._ranking([r0, r1], [r2], status="fallback", personalized=False, fallback_reason="timeout")
PersonalInboxRanking.objects.filter(id=row.id).update(
checked_at=datetime.datetime(2026, 3, 1, 13, 30, tzinfo=datetime.UTC)
)
self._ranking([r2, r1], [], user=teammate)
if params:
params = {**params, "teammate_uuid": teammate.uuid}

with time_machine.travel("2026-03-01T14:00:00Z", tick=False):
first = self._list(sort=sort, limit=2, **params)
later = self._list(sort=sort, limit=2, offset=2, **params)

assert first["personal_ranking_status"] == {
"state": "fallback",
"size": 3,
"age_minutes": 120,
"checked_age_minutes": 30,
"fallback_reason": "timeout",
}
assert later["personal_ranking_status"] is None
assert (self.gather.call_count, self.delay.call_count) == (0, 0)

@parameterized.expand([("flag_off", False, None), ("no_row", True, "none")])
def test_ranking_status_without_a_ranking(self, _name: str, flag_enabled: bool, state: str | None) -> None:
self.flag_enabled = flag_enabled

status = self._list(sort="priority")["personal_ranking_status"]

if state is None:
assert status is None
else:
assert status == {
"state": state,
"size": None,
"age_minutes": None,
"checked_age_minutes": None,
"fallback_reason": None,
}

def test_ranking_status_adds_one_query_to_the_first_page(self) -> None:
r0, r1, *_ = self.reports
self._ranking([r0, r1], [])
self._list(sort="priority")

def queries(payload: dict[str, Any]) -> int:
self.payload = payload
with CaptureQueriesContext(connection) as captured:
self._list(sort="priority")
return len(captured)

assert queries({}) - queries({"enabled": False}) == 1
68 changes: 67 additions & 1 deletion products/signals/backend/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import math
import time
import uuid
import dataclasses
from collections.abc import Callable, Iterable, Sequence
from contextlib import ExitStack
from datetime import UTC, datetime, timedelta
Expand Down Expand Up @@ -165,7 +166,11 @@
SignalUserAutonomyConfig,
)
from products.signals.backend.personal_ranking.config import PERSONAL_INBOX_RANKING_FLAG
from products.signals.backend.personal_ranking.list_sort import PersonalListOrder, personal_list_order
from products.signals.backend.personal_ranking.list_sort import (
PersonalListOrder,
personal_list_order,
personal_ranking_status,
)
from products.signals.backend.personal_ranking.serve import serve_personal_ranking
from products.signals.backend.pr_ci_status import pr_ci_statuses_for_references, rollup_ci_status
from products.signals.backend.presentation.scout_permissions import ScoutCanonicalTeamAccessPermission
Expand Down Expand Up @@ -1533,6 +1538,50 @@
"ranking applied, and for every other sort."
),
}
paginated["properties"]["personal_ranking_status"] = {
"type": "object",
"nullable": True,
"description": (
"Your own stored personal ranking, for every sort and also with scope=teammate. Read on the first "
"page only, and never starts a build. Null on later pages and when personal ranking is off for you."
),
"properties": {
"state": {
"type": "string",
"enum": ["real", "one_report", "not_personalized", "empty", "fallback", "none"],
"description": (
"real: the model personalized 2 or more reports, so the ranking can reorder the list. "
"one_report: it personalized a single report. not_personalized: the build finished "
"without personalizing. empty: the build had no reports to rank. fallback: the build "
"kept the default order, see fallback_reason. none: no ranking is stored."
),
},
"size": {
"type": "integer",
"nullable": True,
"description": "Reports in the stored ranking. Null when state is none.",
},
"age_minutes": {
"type": "integer",
"nullable": True,
"description": "Minutes since the ranking was built. Null when state is none.",
},
"checked_age_minutes": {
"type": "integer",
"nullable": True,
"description": (
"Minutes since a build or the scheduled check last confirmed the ranking's inputs. "
"Null when state is none."
),
},
"fallback_reason": {
"type": "string",
"nullable": True,
"description": "Why the build kept the default order, when state is fallback. Else null.",
},
},
"required": ["state", "size", "age_minutes", "checked_age_minutes", "fallback_reason"],
}
paginated["properties"]["count_is_capped"] = {
"type": "boolean",
"description": (
Expand Down Expand Up @@ -2154,7 +2203,7 @@
return SignalReportListSerializer
return SignalReportSerializer

def safely_get_queryset(self, queryset):

Check warning on line 2206 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`safely_get_queryset` has cyclomatic complexity 14 (warn >10)

Check warning on line 2206 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`safely_get_queryset` has cyclomatic complexity 14 (warn >10)
if self.action == "read_state":
return queryset.filter(team=self.team).exclude(status=SignalReport.Status.DELETED)
if self.action in {"viewed", "feedback_state", "feedback", "pr_ci_statuses", "refresh_metrics", "spend"}:
Expand Down Expand Up @@ -2718,7 +2767,7 @@
# "not already addressed" has never covered reports nobody judged.
return queryset.filter(latest_already_addressed=already_addressed)

def _apply_signal_report_inbox_view_filter(self, queryset):

Check warning on line 2770 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_apply_signal_report_inbox_view_filter` has cyclomatic complexity 13 (warn >10)

Check warning on line 2770 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_apply_signal_report_inbox_view_filter` has cyclomatic complexity 13 (warn >10)
inbox_view = self.request.query_params.get("view")
if not inbox_view:
return queryset
Expand Down Expand Up @@ -3042,6 +3091,19 @@
return self._own_personal_order
return personal_list_order(self.team, owner, request.query_params.get("ranking_id"))

def _personal_ranking_status(self) -> dict[str, object] | None:
"""The caller's own stored ranking, also when `scope=teammate` orders the page by a teammate's."""
request = self.request
user = request.user
if (
is_service_auth(request)
or not isinstance(user, User)
or not user_flag_enabled(PERSONAL_INBOX_RANKING_FLAG, user, self.team)
):
return None
status = personal_ranking_status(self.team, user, timezone.now())
return dataclasses.asdict(status) if status is not None else None

def _personal_ranking_owner(self, user: User) -> User | None:
"""Whose stored ranking `sort=personal` reads: the teammate `scope=teammate` names, else the caller."""
params = self.request.query_params
Expand Down Expand Up @@ -3441,7 +3503,7 @@
parameters=_REPORT_LIST_PARAMETERS,
)
@tracer.start_as_current_span("signals.reports.list")
def list(self, request: ValidatedRequest, *args, **kwargs):

Check warning on line 3506 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`list` has cyclomatic complexity 14 (warn >10)

Check warning on line 3506 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`list` has cyclomatic complexity 14 (warn >10)
count_only: bool = request.validated_query_data["count_only"]
include_source_metadata: bool = request.validated_query_data["include_source_metadata"]
self._source_metadata_available = include_source_metadata
Expand Down Expand Up @@ -3526,6 +3588,10 @@
self._recommended_arm.ranking_pill if self._recommended_arm is not None else None
)
response.data["ranking_id"] = self._personal_order.ranking_id if self._personal_order is not None else None
# Later pages skip the read: the client records the status of the first page only.
response.data["personal_ranking_status"] = (
self._personal_ranking_status() if getattr(self.paginator, "offset", None) == 0 else None
)
return response
return Response(data)

Expand Down Expand Up @@ -4592,7 +4658,7 @@
attribution=self._request_attribution(),
)

def _transition_report_state(

Check warning on line 4661 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`_transition_report_state` has cyclomatic complexity 30 (warn >10)

Check warning on line 4661 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`_transition_report_state` has cyclomatic complexity 30 (warn >10)
self,
report: SignalReport,
*,
Expand Down Expand Up @@ -6067,7 +6133,7 @@
)


def append_suggested_reviewers(

Check warning on line 6136 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

lint:complexity

`append_suggested_reviewers` has cyclomatic complexity 24 (warn >10)

Check warning on line 6136 in products/signals/backend/views.py

View workflow job for this annotation

GitHub Actions / Python code quality (depot-ubuntu-24.04)

`append_suggested_reviewers` has cyclomatic complexity 24 (warn >10)
*,
team: Team,
report_id: str,
Expand Down
34 changes: 34 additions & 0 deletions products/signals/frontend/generated/api.schemas.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading