lct-01: контракт закрыт — координаты, обязательные поля, коды ошибок

Координаты были представлены дважды: кортежем в КИО и объектом в данных
ЭРА-ГЛОНАСС. Теперь Coords {lat, lon} везде — в кортеже не видно, где
широта, и ошибка всплывает на карте у диспетчера, а не в типах.

required_fields едет в call.incoming и session.snapshot: обязательность
полей задаёт сценарий, без списка АРМ не подсветит незаполненное поле,
и курсант узнаёт о неполноте карточки только из разбора.

Канал error получил коды (ErrorKind, восемь значений) — фронт разбирает
код, а не текст сообщения. Имя не ErrorCode: оно занято таксономией E1–E6,
два одинаковых имени дали бы коллизию в generated.ts.

Добавлены поля, которые спрашивает чек-лист, а записать было некуда:
куда идёт дым, уехал ли нарушитель, код домофона.

Документы догнали код: mkh → gkh, payload transcript.append с якорем ref,
причина director у tts.cancel, attempt и stopped у таймеров.
This commit is contained in:
Ivan Gerasimov 2026-09-15 19:48:56 +03:00
commit 59d5a6a26d
5 changed files with 106 additions and 17 deletions

View file

@ -18,7 +18,7 @@ from uuid import UUID
from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.kio import KIO
from app.domain.kio import KIO, Coords
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
@ -67,6 +67,20 @@ class DirectiveMode(StrEnum):
IMMEDIATE = "immediate"
class ErrorKind(StrEnum):
"""Коды канала `error`. Фронт разбирает код, а не текст сообщения:
текст — для человека, код — для поведения интерфейса."""
SESSION_NOT_FOUND = "session_not_found"
CALL_NOT_STARTED = "call_not_started"
HINT_DENIED_IN_EXAM = "hint_denied_in_exam"
MODELS_WARMING_UP = "models_warming_up"
DIRECTIVE_NEEDS_NETWORK = "directive_needs_network"
SCENARIO_INVALID = "scenario_invalid"
UNSUPPORTED_EVENT = "unsupported_event"
INTERNAL = "internal"
class TranscriptEntry(BaseModel):
"""Реплика в ленте. `ref` — якорь для пометок преподавателя и отметок разбора."""
@ -77,20 +91,20 @@ class TranscriptEntry(BaseModel):
mood: Mood | None = None
class Coords(BaseModel):
lat: float
lon: float
# ─────────────────────────── сервер → курсант ───────────────────────────
class CallIncoming(BaseModel):
"""Обязательность полей приходит сценарием, а не моделью: без `required_fields`
АРМ не может подсветить незаполненное обязательное поле, и курсант узнаёт
о неполноте карточки только из разбора."""
type: Literal["call.incoming"] = "call.incoming"
scenario_id: str
caller_number: str
level: Level
mode: SessionMode
required_fields: list[str] = []
class CallStarted(BaseModel):
@ -190,7 +204,7 @@ class ScoreReady(BaseModel):
class ErrorEvent(BaseModel):
type: Literal["error"] = "error"
code: str
code: ErrorKind
message: str
@ -290,6 +304,7 @@ class SessionSnapshot(BaseModel):
trainee_name: str | None = None
started_at: datetime | None = None
kio: KIO
required_fields: list[str] = []
transcript: list[TranscriptEntry]
timers: list[TimerSnapshot]
hints_used: int = 0

View file

@ -15,6 +15,17 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType
class Coords(BaseModel):
"""Координаты одним представлением на весь контракт.
Именованные поля, а не кортеж: в `[55.75, 37.61]` невозможно увидеть,
где широта, а где долгота, и ошибка всплывёт на карте, а не в типах.
"""
lat: float
lon: float
class ResponseStatus(StrEnum):
REGISTERED = "registered"
TRANSFERRED = "transferred"
@ -30,6 +41,7 @@ class FireDetails(BaseModel):
floors: int | None = None
gasified: bool | None = None
people_inside: bool | None = None
smoke_spread: str | None = None # куда идёт дым — пункт чек-листа 01
class PoliceDetails(BaseModel):
@ -37,6 +49,7 @@ class PoliceDetails(BaseModel):
offence_kind: str | None = None
suspects: str | None = None
suspect_fled: bool | None = None # уехал ли нарушитель — пункт чек-листа 02
vehicle: str | None = None
@ -80,7 +93,8 @@ class KIO(BaseModel):
building: str | None = None
entrance: str | None = None
floor: str | None = None
coords: tuple[float, float] | None = None
intercom_code: str | None = None # спрашивается чек-листом 03, без него скорая стоит у двери
coords: Coords | None = None
# Происшествие
incident_type: IncidentType | None = None

View file

@ -25,6 +25,11 @@ HEADER = """// Сгенерировано `make types` из backend/app/domain/e
"""
def oneline(text: str) -> str:
"""Docstring в несколько строк — комментарий в TS в одну."""
return " ".join(text.split())
def pascal(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
@ -90,7 +95,7 @@ def fields(node: dict[str, Any], indent: str = " ") -> list[str]:
optional = "" if name in required or "const" in prop else "?"
description = prop.get("description")
if description:
lines.append(f"{indent}/** {description} */")
lines.append(f"{indent}/** {oneline(description)} */")
lines.append(f"{indent}{name}{optional}: {ts_type(prop)};")
return lines
@ -100,7 +105,7 @@ def inline_object(node: dict[str, Any]) -> str:
def render_def(name: str, node: dict[str, Any]) -> str:
doc = node.get("description", "").strip()
doc = oneline(node.get("description", ""))
comment = f"/** {doc} */\n" if doc else ""
if "enum" in node and "properties" not in node:
values = " | ".join(literal(value) for value in node["enum"])

View file

@ -6,14 +6,18 @@ from uuid import uuid4
import pytest
from pydantic import TypeAdapter, ValidationError
from app.config import Settings
from app.domain.events import (
CallIncoming,
ErrorKind,
EventCatalog,
KioPatchIn,
ServerToTrainee,
SessionMode,
SessionSnapshot,
TraineeToServer,
)
from app.domain.kio import KIO, apply_patch, missing_fields
from app.domain.kio import KIO, Coords, apply_patch, missing_fields
from app.domain.timers import NORMATIVES, TimerCode, TimerState, state_for
from scripts.export_types import OUT, render
@ -83,6 +87,49 @@ def test_event_catalog_covers_every_channel():
}
def test_coords_are_named_everywhere():
"""Одно представление координат на весь контракт: в кортеже не видно,
где широта, и ошибка всплывает на карте, а не в типах."""
card = apply_patch(KIO(), {"coords.lat": 55.751244, "coords.lon": 37.618423})
assert card.coords == Coords(lat=55.751244, lon=37.618423)
def test_required_fields_reach_the_screen():
"""Без этого АРМ не подсветит незаполненное обязательное поле."""
incoming = CallIncoming(
scenario_id="fire-apartment-l2",
caller_number="+7 999 000-00-00",
level="L2",
mode=SessionMode.TRAINING,
required_fields=["address", "floor"],
)
assert incoming.required_fields == ["address", "floor"]
# Наблюдателю они нужны так же: монитор рисует ту же карточку
assert "required_fields" in SessionSnapshot.model_fields
def test_error_channel_has_codes_not_prose():
"""Фронт разбирает код, а не текст: текст — для человека."""
adapter = TypeAdapter(ServerToTrainee)
event = adapter.validate_python(
{"type": "error", "code": "hint_denied_in_exam", "message": "В контрольном режиме подсказок нет"}
)
assert event.code is ErrorKind.HINT_DENIED_IN_EXAM
with pytest.raises(ValidationError):
adapter.validate_python({"type": "error", "code": "что-то пошло не так", "message": ""})
def test_normative_comes_from_config_not_code():
"""Правка норматива не должна требовать правки кода."""
default = Settings()
assert default.limit_ms(TimerCode.INTERVIEW) == 75_000
overridden = Settings(timer_limits_ms={TimerCode.INTERVIEW: 90_000})
assert overridden.limit_ms(TimerCode.INTERVIEW) == 90_000
assert overridden.limit_ms(TimerCode.ANSWER) == 8_000
def test_generated_types_match_models():
"""Забытый `make types` ловится здесь, а не на фронте в последнюю ночь."""
assert OUT.exists(), "нет frontend/src/shared/types/generated.ts — запусти make types"