lct-hack/backend/app/session/finish.py
Ivan Gerasimov 38082e2af3 lct-18: профиль курсанта — радар, история попыток, дельта
Работа над ошибками становится измеримым циклом: посмотрел разбор →
попробовал снова → увидел дельту по оценке, времени опроса и добытым
фактам.

В отчёт занятия добавлена сводка числами — время опроса, факты,
подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором
текста метрик («94 с»), а это путь к тихим ошибкам.

Радар — среднее по попыткам: одна неудачная попытка не определяет
курсанта.

Проверено сквозным тестом на живой базе: два занятия одного курсанта
дают две попытки и посчитанную дельту. Проверка доступности базы в тесте
была ложной — синхронного драйвера в проекте нет, и тест молча
пропускался.
2026-09-17 21:42:12 +03:00

70 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Завершение занятия: посчитать оценку и положить её в журнал.
Детерминированный слой считается сразу по завершении звонка (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 TimerCode
from app.scenarios import store
from app.scoring.competency import radar
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,
end_reason=state.end_reason,
)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («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,
}
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))