"""Завершение занятия: посчитать оценку и положить её в журнал. Детерминированный слой считается сразу по завершении звонка (lct-12). Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет своё ощущение с объективной картиной, и расхождение — отдельный материал для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают событие сразу: им ждать нечего. """ import logging from uuid import UUID from app.domain.events import ScoreReady from app.domain.timers import NORMATIVES, TimerCode from app.scenarios import store from app.scoring.competency import radar from app.scoring.dispatcher import evaluate_dispatcher from app.scoring.gost import evaluate from app.session.hub import hub log = logging.getLogger(__name__) async def finish(session_id: UUID, state) -> None: # Сценарий занятия, а не библиотечный: директивы могли поправить эталон. scenario = state.scenario or store.get(state.scenario_id) if scenario is None: return result = evaluate( scenario=scenario, kio=state.kio, timers=state.timers, revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, refined_facts=list(state.slots.refined) if state.slots else None, end_reason=state.end_reason, bounced_fields=state.bounced_fields, ) # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # участвуют оба (docs/spec/DATASET.md#статусы-реагирования). dispatcher_findings = evaluate_dispatcher( entries=state.status_log, services=state.notified_services(), deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), ) result.findings.extend(dispatcher_findings) # Сводка числами: по ней считается дельта между попытками в профиле. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. required = scenario.ground_truth.required_facts revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else [] codes: dict[str, int] = {} for finding in result.findings: codes[finding.code.value] = codes.get(finding.code.value, 0) + 1 state.score = { "score_auto": result.score, "summary": { "interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW), "facts_got": len([fact for fact in required if fact in revealed]), "facts_required": len(required), "hints": len(state.hints_shown), "codes": codes, }, "metrics": [metric.model_dump() for metric in result.metrics], "findings": [finding.model_dump(mode="json") for finding in result.findings], "competencies": [item.model_dump() for item in radar(result.metrics)], "unavailable": result.unavailable, # Статус карточки — готовая красная метка, понятная любому диспетчеру. "card_status": state.station_snapshot().card.value, } log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings)) if hub.journal: await hub.journal.score(session_id, result.score, state.score) hub.to_observers(session_id, ScoreReady(session_id=session_id)) await release_score(session_id, state) async def release_score(session_id: UUID, state) -> None: """Отдать оценку курсанту, когда самооценка сдана.""" if state.score is not None and state.self_assessed: hub.to_trainee(session_id, ScoreReady(session_id=session_id))