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

@ -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]

View file

@ -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`."""

View file

@ -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",
),

View file

@ -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

View file

@ -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,

View file

@ -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
),