Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -45,11 +45,14 @@ class LessonCriteria(BaseModel):
|
|||
"""Настраиваемые преподавателем условия именно этого занятия.
|
||||
|
||||
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
|
||||
учебный лимит решения, порог успешности и веса метрик; веса сценария
|
||||
остаются базовыми, а настройки занятия могут их переопределить.
|
||||
учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения
|
||||
КИО, порог успешности и веса метрик; веса сценария остаются базовыми,
|
||||
настройки занятия могут их переопределить.
|
||||
"""
|
||||
|
||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
|
@ -156,6 +159,12 @@ class CardBriefing(BaseModel):
|
|||
handoff_to_dds: bool = False
|
||||
|
||||
|
||||
class TextTurnAccepted(BaseModel):
|
||||
type: Literal["text.turn.accepted"] = "text.turn.accepted"
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class CallStarted(BaseModel):
|
||||
type: Literal["call.started"] = "call.started"
|
||||
started_at: datetime
|
||||
|
|
@ -178,6 +187,7 @@ class CallerUtterance(BaseModel):
|
|||
text: str
|
||||
at: datetime
|
||||
mood: Mood
|
||||
source: Literal["local_llm", "scenario"] = "scenario"
|
||||
|
||||
|
||||
class TtsBegin(BaseModel):
|
||||
|
|
@ -251,6 +261,13 @@ class ScoreReady(BaseModel):
|
|||
session_id: UUID
|
||||
|
||||
|
||||
class CommandAck(BaseModel):
|
||||
"""Durable confirmation for a station command, safe to replay by ID."""
|
||||
|
||||
type: Literal["command.ack"] = "command.ack"
|
||||
command_id: UUID
|
||||
|
||||
|
||||
class ErrorEvent(BaseModel):
|
||||
type: Literal["error"] = "error"
|
||||
code: ErrorKind
|
||||
|
|
@ -260,6 +277,7 @@ class ErrorEvent(BaseModel):
|
|||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
| TextTurnAccepted
|
||||
| CallStarted
|
||||
| SttPartial
|
||||
| SttFinal
|
||||
|
|
@ -293,6 +311,11 @@ class CardSubmit(BaseModel):
|
|||
type: Literal["card.submit"] = "card.submit"
|
||||
|
||||
|
||||
class TextTurn(BaseModel):
|
||||
type: Literal["text.turn"] = "text.turn"
|
||||
text: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class KioPatchIn(BaseModel):
|
||||
"""Правка карточки. Дебаунс 300 мс, шлётся только дельта."""
|
||||
|
||||
|
|
@ -347,6 +370,7 @@ class CallResolve(BaseModel):
|
|||
TraineeToServer = Annotated[
|
||||
CallAnswer
|
||||
| CardSubmit
|
||||
| TextTurn
|
||||
| KioPatchIn
|
||||
| HintRequest
|
||||
| SelfAssessmentSubmit
|
||||
|
|
@ -446,6 +470,7 @@ class ScenarioStart(BaseModel):
|
|||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
scenario_ids: list[str] | None = None
|
||||
random_scenario_ids: list[str] | None = None
|
||||
# Pace defaults to simultaneous for older clients; the instructor UI
|
||||
# explicitly sends its slower training default.
|
||||
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
|
||||
|
|
@ -489,11 +514,6 @@ class ScoreOverride(BaseModel):
|
|||
comment: str
|
||||
|
||||
|
||||
class ScenarioPublish(BaseModel):
|
||||
type: Literal["scenario.publish"] = "scenario.publish"
|
||||
scenario_id: str
|
||||
|
||||
|
||||
class SessionStop(BaseModel):
|
||||
type: Literal["session.stop"] = "session.stop"
|
||||
|
||||
|
|
@ -504,7 +524,6 @@ InstructorToServer = Annotated[
|
|||
| ReferencePlay
|
||||
| InstructorNoteAdd
|
||||
| ScoreOverride
|
||||
| ScenarioPublish
|
||||
| SessionStop,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -528,6 +547,7 @@ class CardAck(BaseModel):
|
|||
"""Останавливает норматив `dds_ack` (≤ 30 с)."""
|
||||
|
||||
type: Literal["card.ack"] = "card.ack"
|
||||
comment: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class CardBounce(BaseModel):
|
||||
|
|
@ -644,7 +664,8 @@ class CrewArrived(BaseModel):
|
|||
|
||||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||
| ScoreReady | CommandAck | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -661,7 +682,7 @@ StationToServer = Annotated[
|
|||
|
||||
|
||||
class Metric(BaseModel):
|
||||
"""Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование."""
|
||||
"""Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
|
|
@ -670,6 +691,7 @@ class Metric(BaseModel):
|
|||
ref: str | None = None
|
||||
passed: bool
|
||||
weight: float = 1.0
|
||||
credit: float | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class CompetencyScore(BaseModel):
|
||||
|
|
@ -677,6 +699,17 @@ class CompetencyScore(BaseModel):
|
|||
value: float
|
||||
|
||||
|
||||
class AIRecommendation(BaseModel):
|
||||
metric_key: str
|
||||
text: str = Field(min_length=12, max_length=240)
|
||||
|
||||
|
||||
class AICoaching(BaseModel):
|
||||
status: Literal["ready", "unavailable", "disabled", "not_needed"]
|
||||
model: str | None = None
|
||||
recommendations: list[AIRecommendation] = []
|
||||
|
||||
|
||||
class HintUsage(BaseModel):
|
||||
checklist_id: str
|
||||
question: str
|
||||
|
|
@ -713,6 +746,14 @@ class DdsCardReport(BaseModel):
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]] = []
|
||||
duration_ms: int = 0
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = []
|
||||
|
||||
|
||||
class SessionReport(BaseModel):
|
||||
|
|
@ -721,6 +762,7 @@ class SessionReport(BaseModel):
|
|||
session_id: UUID
|
||||
scenario_id: str
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria
|
||||
failed_metrics: int
|
||||
|
|
@ -728,6 +770,7 @@ class SessionReport(BaseModel):
|
|||
transcript: list[TranscriptEntry]
|
||||
findings: list[Finding]
|
||||
metrics: list[Metric]
|
||||
ai_coaching: AICoaching | None = None
|
||||
card_results: list[DdsCardReport] = []
|
||||
competencies: list[CompetencyScore]
|
||||
reference_questions: list[HintShown]
|
||||
|
|
|
|||
|
|
@ -133,9 +133,30 @@ class KIO(BaseModel):
|
|||
|
||||
#: Поля, которые курсант не редактирует: их проставляет система.
|
||||
READ_ONLY_FIELDS: frozenset[str] = frozenset(
|
||||
{"card_id", "registered_at", "caller_number", "response_status", "incident_code", "notify"}
|
||||
{
|
||||
"card_id", "registered_at", "caller_number", "response_status",
|
||||
"incident_code", "notify", "dispatch_order_at", "arrival_at",
|
||||
}
|
||||
)
|
||||
|
||||
# Поля, которые реально можно заполнить в форме курсантского КИО. Держим
|
||||
# отдельный allowlist: наличие атрибута в модели ещё не означает, что форма
|
||||
# умеет его показать и отправить (например, coords или служебные timestamps).
|
||||
EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
||||
"caller_name", "caller_contact", "phone_on_scene", "language",
|
||||
"okato", "address", "street", "building", "entrance", "floor",
|
||||
"intercom_code", "description", "incident_group", "signs",
|
||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||
"evacuation_needed", "dds",
|
||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||
"fire.people_inside", "fire.smoke_spread",
|
||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||
"police.vehicle",
|
||||
"medical.reason", "medical.conscious", "medical.breathing",
|
||||
"medical.can_move", "medical.age",
|
||||
"utility.failure_kind", "utility.scale", "utility.threat_to_residents",
|
||||
})
|
||||
|
||||
|
||||
def get_field(card: KIO, path: str) -> Any:
|
||||
"""Значение поля по пути вида `floor` или `fire.floors`."""
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ ROLE_LABELS: dict[Role, str] = {
|
|||
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
|
||||
#: чужих результатов.
|
||||
SCREENS: dict[Role, tuple[str, ...]] = {
|
||||
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
|
||||
Role.ADMIN: ("/admin", "/wall", "/profile", "/groups", "/materials"),
|
||||
Role.INSTRUCTOR: (
|
||||
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -74,9 +74,22 @@ NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
|
|||
|
||||
#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
|
||||
#: половина нарушений в памятке — отказ без указания, куда передана информация.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
|
||||
{ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
)
|
||||
# Заказчик уточнил, что диспетчер выбирает статусы и добавляет к ним свои
|
||||
# комментарии. Требуем фиксировать источник/содержание сведений для каждой
|
||||
# ручной отметки, а не обучать проставлению статусов без основания.
|
||||
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.ACCEPTED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED,
|
||||
ServiceStatus.WORKING,
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
REFUSAL_COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset({
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
})
|
||||
|
||||
#: Первичные статусы: их ждут в норматив 30 секунд.
|
||||
PRIMARY: frozenset[ServiceStatus] = frozenset(
|
||||
|
|
@ -198,8 +211,8 @@ def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comme
|
|||
)
|
||||
if status in COMMENT_REQUIRED and not comment.strip():
|
||||
raise StatusError(
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
|
||||
"куда передана информация"
|
||||
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: укажите основание "
|
||||
"отметки и содержание полученных сведений"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -250,6 +263,7 @@ class StationSnapshot(BaseModel):
|
|||
log: list[StatusEntry]
|
||||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
zone_decision: bool | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
phone_lines: list[PhoneLineRecord] = []
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ class ErrorCode(StrEnum):
|
|||
|
||||
|
||||
class FindingSource(StrEnum):
|
||||
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
|
||||
"""Источник объяснимой отметки; `judge` оставлен для старых отчётов."""
|
||||
|
||||
SLOTS = "slots"
|
||||
DISPATCHER = "dispatcher"
|
||||
|
|
@ -42,6 +42,8 @@ class FindingSource(StrEnum):
|
|||
TIMERS = "timers"
|
||||
KIO = "kio"
|
||||
CHAIN = "chain"
|
||||
GRAMMAR = "grammar"
|
||||
# Legacy value: historical saved reports may still contain it.
|
||||
JUDGE = "judge"
|
||||
INSTRUCTOR = "instructor"
|
||||
|
||||
|
|
@ -74,9 +76,9 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
),
|
||||
ErrorCode.E4: ErrorSpec(
|
||||
code=ErrorCode.E4,
|
||||
title="Коммуникативная ошибка",
|
||||
detail="Тон, эмпатия, управление диалогом, лишние вопросы, игнорирование паники",
|
||||
source=FindingSource.JUDGE,
|
||||
title="Грамматическая ошибка",
|
||||
detail="Нарушен включённый критерий грамматики описания КИО",
|
||||
source=FindingSource.GRAMMAR,
|
||||
),
|
||||
ErrorCode.E5: ErrorSpec(
|
||||
code=ErrorCode.E5,
|
||||
|
|
@ -117,8 +119,8 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
|
|||
ErrorCode.D5: ErrorSpec(
|
||||
code=ErrorCode.D5,
|
||||
title="Неполный комментарий",
|
||||
detail="Не указано, куда передана информация и что сделал диспетчер",
|
||||
source=FindingSource.JUDGE,
|
||||
detail="В комментарии не назван получатель переданных сведений",
|
||||
source=FindingSource.DISPATCHER,
|
||||
),
|
||||
ErrorCode.D6: ErrorSpec(
|
||||
code=ErrorCode.D6,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ class TimerCode(StrEnum):
|
|||
INTERVIEW = "interview"
|
||||
DDS_NOTIFY = "dds_notify"
|
||||
DDS_ACK = "dds_ack"
|
||||
DDS_WORK = "dds_work"
|
||||
CARD_FILL = "card_fill"
|
||||
ZONE_CHECK = "zone_check"
|
||||
CALLBACK = "callback"
|
||||
CLOSE = "close"
|
||||
|
|
@ -62,6 +64,18 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
|||
limit_ms=30_000,
|
||||
ref="ПП РФ № 1931",
|
||||
),
|
||||
TimerCode.CARD_FILL: Normative(
|
||||
code=TimerCode.CARD_FILL,
|
||||
title="Заполнение карточки КИО",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.DDS_WORK: Normative(
|
||||
code=TimerCode.DDS_WORK,
|
||||
title="Отработка карточки ДДС",
|
||||
limit_ms=180_000,
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
),
|
||||
TimerCode.ZONE_CHECK: Normative(
|
||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in a new issue