69 lines
3.6 KiB
Python
69 lines
3.6 KiB
Python
|
|
"""Воспроизводимая аналитика учебной группы по сохранённым оценкам.
|
|||
|
|
|
|||
|
|
Рекомендации здесь основаны на кодах ошибок, а не выдаются за вывод ИИ.
|
|||
|
|
Повторные попытки учитываются в среднем балле, но один курсант не может
|
|||
|
|
увеличить долю группы повторением одной и той же ошибки.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from collections import Counter, defaultdict
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
from app.domain.taxonomy import ERRORS, ErrorCode
|
|||
|
|
|
|||
|
|
|
|||
|
|
RECOMMENDATIONS: dict[str, str] = {
|
|||
|
|
"E1": "Повторить опросную карту и обязательные уточняющие вопросы.",
|
|||
|
|
"E2": "Отработать классификацию происшествия и маршрутизацию по ЕКП.",
|
|||
|
|
"E3": "Провести тренировку по временным нормативам.",
|
|||
|
|
"E4": "Разобрать управление разговором и коммуникацию в стрессе.",
|
|||
|
|
"E5": "Потренировать полноту заполнения карточки 112.",
|
|||
|
|
"E6": "Повторить проверку карточки перед завершением и передачей.",
|
|||
|
|
"D1": "Отработать первичную отметку ДДС в течение 30 секунд.",
|
|||
|
|
"D2": "Сверять статусы реагирования с фактическими докладами.",
|
|||
|
|
"D3": "Разобрать профильность обращений и допустимость отказа.",
|
|||
|
|
"D4": "Тренировать обоснование отказа в комментарии.",
|
|||
|
|
"D5": "Тренировать полноту комментария: действие и передача информации.",
|
|||
|
|
"D6": "Повторить цепочку статусов выезда, прибытия и работ.",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class ScoredAttempt:
|
|||
|
|
trainee_id: UUID | None
|
|||
|
|
score: float
|
|||
|
|
codes: dict[str, int]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def summarize(attempts: list[ScoredAttempt], enrolled: int) -> dict:
|
|||
|
|
"""Сводка только по завершённым попыткам с оценкой."""
|
|||
|
|
active = {item.trainee_id for item in attempts if item.trainee_id is not None}
|
|||
|
|
affected: dict[str, set[UUID]] = defaultdict(set)
|
|||
|
|
occurrences: Counter[str] = Counter()
|
|||
|
|
for attempt in attempts:
|
|||
|
|
for code, count in attempt.codes.items():
|
|||
|
|
if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0:
|
|||
|
|
continue
|
|||
|
|
occurrences[code] += count
|
|||
|
|
if attempt.trainee_id is not None:
|
|||
|
|
affected[code].add(attempt.trainee_id)
|
|||
|
|
|
|||
|
|
errors = []
|
|||
|
|
for code in occurrences:
|
|||
|
|
errors.append({
|
|||
|
|
"code": code,
|
|||
|
|
"title": ERRORS[ErrorCode(code)].title,
|
|||
|
|
"affected_trainees": len(affected[code]),
|
|||
|
|
"occurrences": occurrences[code],
|
|||
|
|
"rate_percent": round(100 * len(affected[code]) / len(active), 1) if active else 0.0,
|
|||
|
|
"recommendation": RECOMMENDATIONS[code],
|
|||
|
|
})
|
|||
|
|
errors.sort(key=lambda item: (-item["affected_trainees"], -item["occurrences"], item["code"]))
|
|||
|
|
return {
|
|||
|
|
"enrolled_trainees": enrolled,
|
|||
|
|
"active_trainees": len(active),
|
|||
|
|
"scored_attempts": len(attempts),
|
|||
|
|
"average_score": round(sum(item.score for item in attempts) / len(attempts), 1)
|
|||
|
|
if attempts else None,
|
|||
|
|
"errors": errors,
|
|||
|
|
}
|