Skip to content

Commit c1908b0

Browse files
committed
fix(analytics): flag unfinished expert evaluations five days before deadline
1 parent fba84cc commit c1908b0

5 files changed

Lines changed: 153 additions & 59 deletions

File tree

‎docs/program-manager-analytics-api.md‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Endpoint доступен менеджерам указанной програм
9393
| Участники не завершили онбординг | Есть уникальные зарегистрированные пользователи с `onboarding_stage IS NOT NULL`, включая этап 0. Завершённый онбординг — `NULL`. Подтверждение почты и членство в команде не заменяют онбординг; временного порога нет. |
9494
| Участники без команды | Зарегистрированный пользователь не является ни руководителем, ни `Collaborator` проекта, связанного с этой программой. Команда в другой программе не исключает пользователя; временного порога нет. |
9595
| Работы ожидают оценивания | Только сданные работы. В открытом режиме нет ни одной оценки по критериям программы. В распределённом режиме нет назначений либо хотя бы одно назначение ещё не завершено, включая частично оценённые работы. Временного порога нет. |
96-
| Эксперты задерживают оценивание | Только распределённое оценивание: у эксперта минимум два незавершённых назначения ждут 24 часа либо хотя бы одно — 48 часов. Порог 48 часов имеет критическую серьёзность. Ожидание начинается с более поздней даты: сдача проекта или создание назначения; неизвестная дата сдачи не даёт отсчёта. Несданные работы и завершённые назначения не учитываются в задержке. |
96+
| Эксперты задерживают оценивание | Только распределённое оценивание: задана `datetime_evaluation_ends`, наступил момент за 5 суток до неё (`now >= datetime_evaluation_ends - 5 days`), у эксперта есть хотя бы одно назначение со статусом `pending` или `in_progress` на сданную работу. До дедлайна — `warning`, начиная с дедлайна — `critical`; сигнал остаётся до завершения оценок. Время ожидания 24/48 часов не влияет на появление. Несданные работы и завершённые назначения не создают сигнал. |
9797

9898
Новый счётчик `attention.participants_without_onboarding` использует тот же
9999
предикат, что и список
@@ -312,9 +312,10 @@ overview, scoring, team/invite rules, фильтры, сохранение field
312312
Для `not_ready`: `project_submitted_at`, `waiting_since`, `waiting_seconds` —
313313
`null`. Для `completed` оба поля ожидания — `null`, дата сдачи сохранена.
314314
Если у legacy-сданного проекта `datetime_submitted=null`, статус рассчитывается
315-
обычно, но ожидание остаётся `null`: достоверного начала SLA нет. Такая запись
316-
не объявляется просроченной. Дата создания проекта никогда не подставляется.
317-
Будущая дата даёт 0 секунд ожидания и не создаёт просрочку.
315+
обычно, но ожидание остаётся `null`: достоверного начала ожидания нет. Это не
316+
мешает сигналу по дедлайну оценивания. Дата создания проекта никогда не
317+
подставляется. Будущая дата даёт 0 секунд ожидания; длительность ожидания не
318+
определяет появление пункта «Эксперты задерживают оценивание».
318319

319320
## Оценки назначения
320321

@@ -374,16 +375,25 @@ overview, scoring, team/invite rules, фильтры, сохранение field
374375
Существующие `participants_without_team` и `projects_awaiting_evaluation`
375376
сохраняются. В open-режиме всегда `{"total": 0, "items": []}`.
376377

377-
SLA учитывает только сданные, незавершённые назначения с известным наступившим
378-
`waiting_since`:
378+
Сигнал использует только отдельную дату окончания оценки программы
379+
`datetime_evaluation_ends`. Если она не задана, список пуст: даты окончания
380+
программы, регистрации или подачи проектов не подставляются.
379381

380-
- `warning`: минимум 2 назначения ждут каждое >= 24 часов;
381-
- `critical`: хотя бы 1 назначение ждёт >= 48 часов (имеет приоритет).
382+
- До `datetime_evaluation_ends - timedelta(days=5)` список пуст, независимо
383+
от времени ожидания и числа назначений.
384+
- Начиная ровно за 5 суток до дедлайна эксперт попадает в список, если у него
385+
есть хотя бы одно назначение со статусом `pending` или `in_progress`.
386+
- `warning`: дедлайн ещё не наступил; `critical`: `now >= datetime_evaluation_ends`.
387+
- После дедлайна сигнал сохраняется до завершения всех оценок сданных работ.
382388

383-
Один проект, ожидающий 25 часов, не даёт предупреждение. Выполненные,
384-
несданные, будущие и назначения другой программы не создают просрочку.
389+
Свежие назначения и legacy-сданные работы без даты сдачи учитываются:
390+
известная длительность ожидания не требуется. Выполненные, несданные и
391+
назначения другой программы не создают сигнал.
385392
`assignments_total`, `completed`, `pending` включают все реальные назначения
386393
эксперта текущей программы, включая несданные в `pending`.
394+
`overdue_24h` и `overdue_48h` сохранены для совместимости как сведения о
395+
длительности ожидания; они не определяют включение в список или серьёзность.
396+
Это пункт аналитики; уведомления экспертам не отправляются.
387397

388398
```json
389399
{
@@ -409,17 +419,20 @@ SLA учитывает только сданные, незавершённые
409419
}
410420
```
411421

412-
Сортировка: critical перед warning, затем большее время ожидания, затем
413-
`expert_id` по возрастанию. Старейшее ожидание берётся среди незавершённых
414-
сданных назначений с известной датой, а не по дате создания проекта.
422+
Сортировка: известное время ожидания перед неизвестным, затем большее время
423+
ожидания, затем `expert_id` по возрастанию. Старейшее ожидание берётся среди
424+
незавершённых сданных назначений с известной датой, а не по дате создания
425+
проекта. При неизвестной дате оба поля `oldest_waiting_*` равны `null`;
426+
нулевое ожидание сохраняется как 0. Серьёзность одинакова для дедлайна программы.
415427

416428
## Производительность и границы
417429

418430
Прогресс, дата сдачи и безопасные поля пользователя/проекта выбираются одним
419431
SQL SELECT: связанные таблицы через JOIN, число критериев и DISTINCT-оценок —
420432
через Subquery/Count; связь проекта с программой также через Subquery.
421433
Нет запросов из сериализаторов и отдельных SQL-запросов в цикле назначений.
422-
Сводка использует тот же список для SLA и счётчиков назначений. Статусы работ
434+
Сводка использует тот же список и один момент `now` для сигнала по дедлайну
435+
и счётчиков назначений. Статусы работ
423436
вычисляет общий SQL-queryset сводки и списка ожидающих работ; проверка завершения
424437
назначения общая с assignments. Внешние поля сводки и assignments не меняются.
425438
Score drilldown добавляет два фиксированных запроса (критерии и оценки пары).

‎partner_programs/serializers/analytics.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ class DelayedExpertSerializer(AssignmentExpertSerializer):
120120
pending = serializers.IntegerField(min_value=0)
121121
overdue_24h = serializers.IntegerField(min_value=0)
122122
overdue_48h = serializers.IntegerField(min_value=0)
123-
oldest_waiting_since = serializers.DateTimeField()
124-
oldest_waiting_seconds = serializers.IntegerField(min_value=0)
123+
oldest_waiting_since = serializers.DateTimeField(allow_null=True)
124+
oldest_waiting_seconds = serializers.IntegerField(min_value=0, allow_null=True)
125125
severity = serializers.ChoiceField(choices=("critical", "warning"))
126126

127127

‎partner_programs/services/analytics.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,10 +355,11 @@ def _get_activity(program_id: int) -> list[dict]:
355355

356356
def build_program_manager_analytics(program) -> dict:
357357
program_id = program.id
358+
now = timezone.now()
358359
participants = _get_participant_metrics(program_id)
359360
regions = _get_regions(program_id)
360361
participant_regions = _get_participant_regions(program_id)
361-
assignment_items = build_assignments(program_id)
362+
assignment_items = build_assignments(program_id, now=now)
362363
assignments = _get_assignment_metrics(assignment_items)
363364
solutions = _get_solution_metrics(program)
364365

@@ -415,7 +416,11 @@ def build_program_manager_analytics(program) -> dict:
415416
"total": solutions["not_submitted"] if program.is_competitive else 0,
416417
},
417418
"delayed_experts": (
418-
build_delayed_experts(assignment_items)
419+
build_delayed_experts(
420+
assignment_items,
421+
evaluation_ends=program.datetime_evaluation_ends,
422+
now=now,
423+
)
419424
if program.is_distributed_evaluation
420425
else {"total": 0, "items": []}
421426
),

‎partner_programs/services/assignment_analytics.py‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Read-only analytics of real expert assignments; no scoring/lifecycle writes."""
22

3+
from datetime import timedelta
4+
35
from django.db.models import BooleanField, Case, Count, F, OuterRef, Subquery, Value, When
46
from django.db.models.functions import Coalesce
57
from django.utils import timezone
@@ -104,7 +106,7 @@ def build_assignment(row, *, now):
104106
submitted_at = row["project_submitted_at"] if row["project_submitted"] else None
105107
waiting_since = None
106108
waiting_seconds = None
107-
# Missing historical submission timestamps cannot establish an SLA start.
109+
# Missing historical submission timestamps cannot establish waiting duration.
108110
if status not in ("not_ready", "completed") and submitted_at is not None:
109111
waiting_since = max(submitted_at, row["datetime_created"])
110112
waiting_seconds = max(0, int((now - waiting_since).total_seconds()))
@@ -131,8 +133,8 @@ def build_assignment(row, *, now):
131133
}
132134

133135

134-
def build_assignments(program_id):
135-
now = timezone.now()
136+
def build_assignments(program_id, *, now=None):
137+
now = now if now is not None else timezone.now()
136138
return [build_assignment(row, now=now) for row in assignment_rows(program_id)]
137139

138140

@@ -160,8 +162,14 @@ def build_assignment_scores(program_id, assignment):
160162
]
161163

162164

163-
def build_delayed_experts(assignments):
165+
def build_delayed_experts(assignments, *, evaluation_ends, now=None):
166+
"""Flag unfinished submitted work from five days before evaluation closes."""
167+
now = now if now is not None else timezone.now()
168+
if evaluation_ends is None or now < evaluation_ends - timedelta(days=5):
169+
return {"total": 0, "items": []}
170+
164171
experts = {}
172+
unfinished_experts = set()
165173
for assignment in assignments:
166174
expert = assignment["expert"]
167175
item = experts.setdefault(
@@ -179,12 +187,14 @@ def build_delayed_experts(assignments):
179187
)
180188
item["assignments_total"] += 1
181189
item["completed" if assignment["status"] == "completed" else "pending"] += 1
190+
if assignment["status"] in ("pending", "in_progress"):
191+
unfinished_experts.add(expert["expert_id"])
182192
seconds = assignment["waiting_seconds"]
183193
if seconds is None:
184194
continue
185195
item["overdue_24h"] += int(seconds >= 24 * 3600)
186196
item["overdue_48h"] += int(seconds >= 48 * 3600)
187-
if seconds > 0 and (
197+
if (
188198
item["oldest_waiting_seconds"] is None
189199
or seconds > item["oldest_waiting_seconds"]
190200
):
@@ -193,17 +203,14 @@ def build_delayed_experts(assignments):
193203

194204
items = []
195205
for item in experts.values():
196-
if item["overdue_48h"] >= 1:
197-
item["severity"] = "critical"
198-
elif item["overdue_24h"] >= 2:
199-
item["severity"] = "warning"
200-
else:
206+
if item["expert_id"] not in unfinished_experts:
201207
continue
208+
item["severity"] = "critical" if now >= evaluation_ends else "warning"
202209
items.append(item)
203210
items.sort(
204211
key=lambda item: (
205-
item["severity"] != "critical",
206-
-item["oldest_waiting_seconds"],
212+
item["oldest_waiting_seconds"] is None,
213+
-(item["oldest_waiting_seconds"] or 0),
207214
item["expert_id"],
208215
)
209216
)

0 commit comments

Comments
 (0)