Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
69
backend/app/scoring/group.py
Normal file
69
backend/app/scoring/group.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Воспроизводимая аналитика учебной группы по сохранённым оценкам.
|
||||
|
||||
Рекомендации здесь основаны на кодах ошибок, а не выдаются за вывод ИИ.
|
||||
Повторные попытки учитываются в среднем балле, но один курсант не может
|
||||
увеличить долю группы повторением одной и той же ошибки.
|
||||
"""
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ from uuid import UUID
|
|||
|
||||
from app.domain.events import (
|
||||
CompetencyScore,
|
||||
DdsCardReport,
|
||||
HintShown,
|
||||
HintUsage,
|
||||
InstructorNoteShown,
|
||||
|
|
@ -68,23 +69,27 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
step.checklist_id
|
||||
for step in reference.steps
|
||||
if step.required and revealed is not None and step.fact_id not in revealed
|
||||
] if state.exercise is not Exercise.CARD else []
|
||||
] if state.exercise is Exercise.CALL else []
|
||||
|
||||
return SessionReport(
|
||||
session_id=session_id,
|
||||
scenario_id=scenario.id,
|
||||
scenario_id=(state.dds_scenarios[0].id
|
||||
if state.exercise is Exercise.DDS and state.dds_scenarios
|
||||
else scenario.id),
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
transcript=list(state.transcript),
|
||||
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
|
||||
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
|
||||
card_results=[DdsCardReport.model_validate(item)
|
||||
for item in score.get("card_results", [])],
|
||||
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],
|
||||
# Эталонные вопросы по шагам: по каждому пропущенному пункту видно,
|
||||
# какой вопрос был правильным.
|
||||
reference_questions=[
|
||||
HintShown(checklist_id=step.checklist_id, question=step.question)
|
||||
for step in reference.steps
|
||||
] if state.exercise is not Exercise.CARD else [],
|
||||
] if state.exercise is Exercise.CALL else [],
|
||||
missed_checklist=missed_checklist,
|
||||
hints_used=[
|
||||
HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_contact": (ErrorCode.D6, Competency.COMMUNICATION),
|
||||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
10
backend/app/scoring/weights.py
Normal file
10
backend/app/scoring/weights.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Пересчёт предварительной шкалы под утверждённый методистом сценарий."""
|
||||
|
||||
from app.scoring.gost import GostResult
|
||||
|
||||
|
||||
def apply_weights(result: GostResult, overrides: dict[str, float]) -> None:
|
||||
"""Меняет только веса метрик; факты, нормативы и ошибки неизменны."""
|
||||
for metric in result.metrics:
|
||||
if metric.key in overrides:
|
||||
metric.weight = overrides[metric.key]
|
||||
Loading…
Reference in a new issue