190 lines
9.7 KiB
Python
190 lines
9.7 KiB
Python
"""Завершение занятия: посчитать оценку и положить её в журнал.
|
||
|
||
Детерминированный слой считается сразу по завершении звонка (lct-12).
|
||
Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет
|
||
своё ощущение с объективной картиной, и расхождение — отдельный материал
|
||
для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают
|
||
событие сразу: им ждать нечего.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
from uuid import UUID
|
||
|
||
from app.domain.events import Exercise, ScoreReady
|
||
from app.domain.timers import TimerCode
|
||
from app.scenarios import store
|
||
from app.scoring.card import evaluate_card
|
||
from app.scoring.competency import radar
|
||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||
from app.scoring.gost import GostResult, evaluate
|
||
from app.scoring.report import build as build_report
|
||
from app.scoring.weights import apply_weights
|
||
from app.session.hub import hub
|
||
from app.session.state import DdsCardRecord, now_utc
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def score_current_dds(state) -> DdsCardRecord:
|
||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||
number = state.dds_card_index + 1
|
||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||
findings = evaluate_dispatcher(
|
||
entries=state.status_log,
|
||
services=state.managed_services(),
|
||
deadline_ms=decision_limit_ms,
|
||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||
)
|
||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||
weighted = GostResult(metrics=metrics, findings=findings)
|
||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||
actions = [
|
||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||
"comment": mark.comment, "at": mark.at.isoformat()}
|
||
for mark in state.status_log
|
||
] + [
|
||
{"type": "phone.report", "service": report.service, "crew": report.crew,
|
||
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
|
||
for report in state.phone_reports
|
||
] + [
|
||
{"type": "card.reply", "text": text, "at": at.isoformat()}
|
||
for at, text in state.reply_log
|
||
] + [
|
||
{"type": kind, "text": text or "", "at": at.isoformat()}
|
||
for kind, at, text in state.dds_log
|
||
]
|
||
actions.sort(key=lambda item: item["at"])
|
||
return DdsCardRecord(
|
||
card_id=state.dispatched_card.card_id,
|
||
scenario_id=state.scenario_id,
|
||
reply_text=state.reply_text,
|
||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
||
for metric in weighted.metrics],
|
||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
||
for finding in weighted.findings],
|
||
actions=actions,
|
||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||
if state.dispatched_at else 0),
|
||
)
|
||
|
||
|
||
async def finish(session_id: UUID, state) -> None:
|
||
if state.recorder is not None:
|
||
path = await asyncio.to_thread(state.recorder.finalize)
|
||
state.recording_path = str(path) if path else None
|
||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||
scenario = state.scenario or store.get(state.scenario_id)
|
||
if scenario is None:
|
||
return
|
||
|
||
if state.exercise is Exercise.CARD:
|
||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||
elif state.exercise is Exercise.DDS:
|
||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||
result = GostResult()
|
||
cards = list(state.dds_completed)
|
||
state.capture_active_dds()
|
||
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
|
||
if any(item.card_id == live.card_id for item in cards):
|
||
continue
|
||
state.activate_dds_card(live.card_id, capture=False)
|
||
cards.append(score_current_dds(state))
|
||
state.dds_completed = cards
|
||
for card in cards:
|
||
result.metrics.extend(card.metrics)
|
||
result.findings.extend(card.findings)
|
||
else:
|
||
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,
|
||
resolved_outcome=state.resolved_outcome,
|
||
dispatched=state.dispatched_card is not None,
|
||
)
|
||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||
if state.dispatched_card is not None and (
|
||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||
):
|
||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||
dispatcher_findings = evaluate_dispatcher(
|
||
entries=state.status_log,
|
||
services=state.managed_services(),
|
||
deadline_ms=decision_limit_ms,
|
||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||
)
|
||
result.findings.extend(dispatcher_findings)
|
||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||
# DDS cards were weighted individually in score_current_dds using each
|
||
# card's scenario defaults plus the lesson override. Reapplying the first
|
||
# scenario's weights here would corrupt the other ticket cards.
|
||
if state.exercise is not Exercise.DDS:
|
||
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
||
|
||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||
# Вытаскивать её разбором текста метрик («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,
|
||
"card_results": [
|
||
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
|
||
"score_auto": card.score_auto, "reply_text": card.reply_text,
|
||
"actions": card.actions, "duration_ms": card.duration_ms,
|
||
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
|
||
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
|
||
for card in cards
|
||
] if state.exercise is Exercise.DDS else [],
|
||
}
|
||
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||
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 refresh_archived_report(session_id: UUID, state) -> None:
|
||
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
|
||
if state.score is None:
|
||
return
|
||
scenario = state.scenario or store.get(state.scenario_id)
|
||
if scenario is None:
|
||
return
|
||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||
if hub.journal:
|
||
await hub.journal.score_snapshot(session_id, state.score)
|
||
|
||
|
||
async def release_score(session_id: UUID, state) -> None:
|
||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||
hub.to_trainee(session_id, ScoreReady(session_id=session_id))
|