Implement local task 09 training workflows

This commit is contained in:
andreysk0304 2026-09-21 17:40:54 +03:00
commit cec84ffcd0
45 changed files with 2679 additions and 366 deletions

View file

@ -10,12 +10,13 @@
import logging
from uuid import UUID
from app.domain.events import ScoreReady
from app.domain.events import Exercise, 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.scoring.card import evaluate_card
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.session.hub import hub
log = logging.getLogger(__name__)
@ -27,27 +28,36 @@ async def finish(session_id: UUID, state) -> None:
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,
resolved_outcome=state.resolved_outcome,
dispatched=state.dispatched_card is not None,
)
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
elif state.exercise is Exercise.DDS:
# В готовой карточке не было входящего звонка, опроса и действий
# оператора 112. Ни одна E-метрика здесь не применима.
result = GostResult()
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#статусы-реагирования).
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)
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
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)
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@ -84,5 +94,5 @@ async def finish(session_id: UUID, state) -> None:
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and state.self_assessed:
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))

View file

@ -28,8 +28,9 @@ class Journal(Protocol):
но молчать об ошибке записи нельзя."""
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
) -> int: ...
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...

View file

@ -31,9 +31,10 @@ class DbJournal:
log.exception("журнал: запись не удалась")
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None
) -> int:
"""Завести сессию в журнале и вернуть номер попытки.
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки
деградирует до первого, и это видно в логе.
@ -46,11 +47,12 @@ class DbJournal:
scenario_id=scenario_id,
mode=mode,
trainee_name=trainee_name,
trainee_id=trainee_id,
)
return row.attempt
return row.attempt, row.trainee_id
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось")
return 1
return 1, trainee_id
async def utterance(self, session_id: UUID, entry) -> None:
await self._write(

View file

@ -12,6 +12,7 @@ from uuid import UUID
from app.domain.events import (
CallEndReason,
Exercise,
Mood,
SessionMode,
SessionSnapshot,
@ -27,12 +28,13 @@ from app.domain.statuses import (
ServiceStatus,
StationSnapshot,
StatusEntry,
PhoneReportRecord,
card_status,
check,
current,
)
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, apply_patch
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.session.timers import SessionTimers
@ -48,6 +50,7 @@ class SessionState:
scenario_title: str
level: str
mode: SessionMode
exercise: Exercise = Exercise.CALL
required_fields: list[str] = field(default_factory=list)
trainee_name: str | None = None
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
@ -96,6 +99,9 @@ class SessionState:
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
#: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
resolve_comment: str = ""
@ -119,10 +125,12 @@ class SessionState:
self.kio = apply_patch(self.kio, fields)
return self.kio
def dispatch(self, service: str) -> KIO:
def dispatch(self, service: str | None = None) -> KIO:
"""Карточка замораживается снимком: оператор не должен иметь
возможности дописать задним числом поле, которое забыл."""
self.kio = apply_patch(self.kio, {"dds": service, "response_status": "transferred"})
if service is not None:
self.kio = apply_patch(self.kio, {"dds": service})
self.kio.response_status = ResponseStatus.TRANSFERRED
self.dispatched_card = self.kio.model_copy(deep=True)
self.dispatched_at = now_utc()
return self.dispatched_card
@ -133,6 +141,14 @@ class SessionState:
card = self.dispatched_card or self.kio
return list(card.notify)
def crew_options(self) -> list[str]:
return [f"{service} — бригада №{number}"
for service in self.notified_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None:
return next((service for service in self.notified_services()
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status(
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
) -> StatusEntry:
@ -161,6 +177,9 @@ class SessionState:
available={service: list(NEXT[status]) for service, status in statuses.items()},
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
log=list(self.status_log),
crew_options=self.crew_options(),
crew_selected=self.crew_selected,
phone_reports=list(self.phone_reports),
)
def card_received_event(self):
@ -169,7 +188,8 @@ class SessionState:
return CardReceived(
card=self.dispatched_card,
from_operator=self.trainee_name or "оператор 112",
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
else self.trainee_name or "оператор 112"),
at=self.dispatched_at or now_utc(),
)
@ -186,6 +206,7 @@ class SessionState:
scenario_title=self.scenario_title,
level=self.level,
mode=self.mode,
exercise=self.exercise,
trainee_name=self.trainee_name,
started_at=self.started_at,
kio=self.kio,