Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -9,16 +9,22 @@
import asyncio
import logging
import time
from uuid import UUID
from app.domain.events import Exercise, ScoreReady
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
from app.domain.statuses import ServiceStatus, current
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
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.grammar import assess
from app.scoring.report import build as build_report
from app.scoring.taxonomy import METRIC_WEIGHTS
from app.scoring.timing import time_credit
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
@ -30,14 +36,67 @@ def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dds_decision = state.scenario.dds_decision
findings = evaluate_dispatcher(
entries=state.status_log,
services=state.managed_services(),
crew_assignments=state.crew_assignments,
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
)
metrics = dispatcher_metrics(
state, decision_limit_ms, dds_decision.expected, dds_decision.reason
)
metrics = dispatcher_metrics(state, decision_limit_ms)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK]
work_timer = state.timers.timers.get(TimerCode.DDS_WORK)
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK)
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
work_elapsed_ms = work_timer.current_ms(time.monotonic())
terminal = bool(state.managed_services()) and all(
current(state.status_log, service) in {
ServiceStatus.COMPLETED,
ServiceStatus.DECLINED,
ServiceStatus.REFUSED,
}
for service in state.managed_services()
)
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
if work_elapsed_ms is None:
work_fact = "время обработки не зафиксировано"
elif work_delta_ms and work_delta_ms > 0:
work_fact = f"{round(work_elapsed_ms / 1000)} с (+{round(work_delta_ms / 1000)} с сверх норматива)"
elif work_delta_ms and work_delta_ms < 0:
work_fact = (f"{round(work_elapsed_ms / 1000)} с (на "
f"{round(abs(work_delta_ms) / 1000)} с быстрее норматива)")
else:
work_fact = f"{round(work_elapsed_ms / 1000)} с (точно в норматив)"
if not terminal:
work_fact = f"карточка не завершена; {work_fact}"
weighted.metrics.append(Metric(
key="dds_work_time",
title="Отработка карточки ДДС",
fact=work_fact,
norm=f"завершить за {round(work_limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
passed=work_passed,
weight=METRIC_WEIGHTS["dds_work_time"],
credit=time_credit(work_elapsed_ms, work_limit_ms) if terminal else 0.0,
))
if not work_passed:
weighted.findings.append(Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
summary="ДДС: норматив времени отработки карточки не выполнен",
fact=work_fact,
norm=f"завершить за {round(work_limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
competency=Competency.NORMS,
))
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
@ -66,6 +125,15 @@ def score_current_dds(state) -> DdsCardRecord:
actions=actions,
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
if state.dispatched_at else 0),
title=state.scenario.title,
address=state.dispatched_card.address,
description=state.dispatched_card.description,
incident_type=(state.dispatched_card.incident_type.value
if state.dispatched_card.incident_type else None),
victims_count=state.dispatched_card.victims_count,
received_at=state.dispatched_at,
managed_service=(state.managed_services()[0] if state.managed_services() else None),
recipient_services=list(state.dispatched_card.notify),
)
@ -74,12 +142,52 @@ async def finish(session_id: UUID, state) -> 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)
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
else state.scenario or store.get(state.scenario_id))
if scenario is None:
return
cards: list[DdsCardRecord] = []
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
result = evaluate_card(
scenario, state.operator_kio or state.dispatched_card or state.kio
)
limit_ms = state.timers.limits[TimerCode.CARD_FILL]
elapsed_ms = state.timers.measured_ms(TimerCode.CARD_FILL)
submitted = state.dispatched_card is not None
if elapsed_ms is None:
elapsed_fact = "время не зафиксировано"
else:
delta_ms = elapsed_ms - limit_ms
elapsed_seconds = round(elapsed_ms / 1000)
if delta_ms > 0:
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
elapsed_fact = f"{elapsed_seconds} с ({deviation})"
passed = submitted and elapsed_ms is not None and elapsed_ms <= limit_ms
result.metrics.append(Metric(
key="card_fill_time",
title="Время заполнения карточки",
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
passed=passed,
weight=METRIC_WEIGHTS["card_fill_time"],
credit=time_credit(elapsed_ms, limit_ms) if submitted else 0.0,
))
if not passed:
result.findings.append(Finding(
code=ErrorCode.E3,
source=FindingSource.TIMERS,
summary="Время заполнения карточки: норматив не выполнен",
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
competency=Competency.NORMS,
))
elif state.exercise is Exercise.DDS:
# Все карточки выданы одновременно: при досрочном завершении оцениваем
# каждую, включая не открытую, потому что её норматив уже шёл.
@ -107,27 +215,74 @@ async def finish(session_id: UUID, state) -> None:
resolved_outcome=state.resolved_outcome,
dispatched=state.dispatched_card is not None,
)
# Грамматика относится к свободному описанию оператора 112, а не к
# заполнению карточки на стороне ДДС. Учитывается только по явной настройке
# преподавателя и только когда есть текст для проверки.
operator_kio = state.operator_kio if state.handoff_to_dds else None
description = (operator_kio or state.kio).description or ""
if (state.exercise is not Exercise.DDS and state.criteria.require_correct_grammar
and description.strip()):
grammar = await assess(description)
result.metrics.append(Metric(
key="description_grammar",
title="Грамматика описания происшествия",
fact="ошибок не обнаружено" if grammar.passed else "; ".join(grammar.errors),
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
passed=grammar.passed,
weight=1.0,
))
if not grammar.passed:
result.findings.append(Finding(
code=ErrorCode.E4,
source=FindingSource.GRAMMAR,
summary="Грамматическая ошибка в описании происшествия",
fact="; ".join(grammar.errors),
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки 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
):
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.managed_services(),
crew_assignments=state.crew_assignments,
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
)
result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
result.metrics.extend(dispatcher_metrics(
state, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason,
))
# 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})
if state.handoff_to_dds and state.dds_scenarios:
# В связке КИО оценивается относительно эталона и весов упражнения
# 112, а каждая карточка очереди уже взвешена собственным сценарием.
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)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
required = scenario.ground_truth.required_facts
@ -140,6 +295,7 @@ async def finish(session_id: UUID, state) -> None:
"score_auto": result.score,
"summary": {
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
"facts_got": len([fact for fact in required if fact in revealed]),
"facts_required": len(required),
"hints": len(state.hints_shown),
@ -155,18 +311,28 @@ async def finish(session_id: UUID, state) -> None:
{"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,
"title": card.title, "address": card.address,
"description": card.description, "incident_type": card.incident_type,
"victims_count": card.victims_count,
"received_at": card.received_at.isoformat() if card.received_at else None,
"managed_service": card.managed_service,
"recipient_services": card.recipient_services,
"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 [],
] if state.exercise is Exercise.DDS or state.handoff_to_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)
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.INTERNAL,
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
))
return
hub.to_observers(session_id, ScoreReady(session_id=session_id))
await release_score(session_id, state)