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

View file

@ -15,6 +15,17 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType from app.domain.classifiers import DDSCode, IncidentType
class Coords(BaseModel):
"""Координаты одним представлением на весь контракт.
Именованные поля, а не кортеж: в `[55.75, 37.61]` невозможно увидеть,
где широта, а где долгота, и ошибка всплывёт на карте, а не в типах.
"""
lat: float
lon: float
class ResponseStatus(StrEnum): class ResponseStatus(StrEnum):
REGISTERED = "registered" REGISTERED = "registered"
TRANSFERRED = "transferred" TRANSFERRED = "transferred"
@ -30,6 +41,7 @@ class FireDetails(BaseModel):
floors: int | None = None floors: int | None = None
gasified: bool | None = None gasified: bool | None = None
people_inside: bool | None = None people_inside: bool | None = None
smoke_spread: str | None = None # куда идёт дым — пункт чек-листа 01
class PoliceDetails(BaseModel): class PoliceDetails(BaseModel):
@ -37,6 +49,7 @@ class PoliceDetails(BaseModel):
offence_kind: str | None = None offence_kind: str | None = None
suspects: str | None = None suspects: str | None = None
suspect_fled: bool | None = None # уехал ли нарушитель — пункт чек-листа 02
vehicle: str | None = None vehicle: str | None = None
@ -80,7 +93,8 @@ class KIO(BaseModel):
building: str | None = None building: str | None = None
entrance: str | None = None entrance: str | None = None
floor: 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 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: def pascal(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_")) 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 "?" optional = "" if name in required or "const" in prop else "?"
description = prop.get("description") description = prop.get("description")
if description: if description:
lines.append(f"{indent}/** {description} */") lines.append(f"{indent}/** {oneline(description)} */")
lines.append(f"{indent}{name}{optional}: {ts_type(prop)};") lines.append(f"{indent}{name}{optional}: {ts_type(prop)};")
return lines return lines
@ -100,7 +105,7 @@ def inline_object(node: dict[str, Any]) -> str:
def render_def(name: str, 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 "" comment = f"/** {doc} */\n" if doc else ""
if "enum" in node and "properties" not in node: if "enum" in node and "properties" not in node:
values = " | ".join(literal(value) for value in node["enum"]) values = " | ".join(literal(value) for value in node["enum"])

View file

@ -6,14 +6,18 @@ from uuid import uuid4
import pytest import pytest
from pydantic import TypeAdapter, ValidationError from pydantic import TypeAdapter, ValidationError
from app.config import Settings
from app.domain.events import ( from app.domain.events import (
CallIncoming,
ErrorKind,
EventCatalog, EventCatalog,
KioPatchIn, KioPatchIn,
ServerToTrainee, ServerToTrainee,
SessionMode, SessionMode,
SessionSnapshot,
TraineeToServer, 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 app.domain.timers import NORMATIVES, TimerCode, TimerState, state_for
from scripts.export_types import OUT, render 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(): def test_generated_types_match_models():
"""Забытый `make types` ловится здесь, а не на фронте в последнюю ночь.""" """Забытый `make types` ловится здесь, а не на фронте в последнюю ночь."""
assert OUT.exists(), "нет frontend/src/shared/types/generated.ts — запусти make types" assert OUT.exists(), "нет frontend/src/shared/types/generated.ts — запусти make types"

View file

@ -30,12 +30,14 @@ export interface CallHangup {
type: "call.hangup"; type: "call.hangup";
} }
/** Обязательность полей приходит сценарием, а не моделью: без `required_fields` АРМ не может подсветить незаполненное обязательное поле, и курсант узнаёт о неполноте карточки только из разбора. */
export interface CallIncoming { export interface CallIncoming {
type: "call.incoming"; type: "call.incoming";
scenario_id: string; scenario_id: string;
caller_number: string; caller_number: string;
level: Level; level: Level;
mode: SessionMode; mode: SessionMode;
required_fields?: Array<string>;
} }
export interface CallStarted { export interface CallStarted {
@ -84,6 +86,7 @@ export interface CompetencyScore {
value: number; value: number;
} }
/** Координаты одним представлением на весь контракт. Именованные поля, а не кортеж: в `[55.75, 37.61]` невозможно увидеть, где широта, а где долгота, и ошибка всплывёт на карте, а не в типах. */
export interface Coords { export interface Coords {
lat: number; lat: number;
lon: number; lon: number;
@ -131,10 +134,13 @@ export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
export interface ErrorEvent { export interface ErrorEvent {
type: "error"; type: "error";
code: string; code: ErrorKind;
message: string; message: string;
} }
/** Коды канала `error`. Фронт разбирает код, а не текст сообщения: текст — для человека, код — для поведения интерфейса. */
export type ErrorKind = "session_not_found" | "call_not_started" | "hint_denied_in_exam" | "models_warming_up" | "directive_needs_network" | "scenario_invalid" | "unsupported_event" | "internal";
/** Отметка в разборе. `fact` и `norm` — то самое обоснование. */ /** Отметка в разборе. `fact` и `norm` — то самое обоснование. */
export interface Finding { export interface Finding {
code: ErrorCode; code: ErrorCode;
@ -158,6 +164,7 @@ export interface FireDetails {
floors?: number | null; floors?: number | null;
gasified?: boolean | null; gasified?: boolean | null;
people_inside?: boolean | null; people_inside?: boolean | null;
smoke_spread?: string | null;
} }
/** В режиме `exam` сервер отвечает событием `error`. */ /** В режиме `exam` сервер отвечает событием `error`. */
@ -194,8 +201,7 @@ export interface InstructorNoteShown {
author: string; author: string;
} }
/** Полная карточка. Наблюдателям уходит целиком (`kio.state`), /** Полная карточка. Наблюдателям уходит целиком (`kio.state`), курсанту — дельтой (`kio.patch`). */
курсанту — дельтой (`kio.patch`). */
export interface KIO { export interface KIO {
card_id?: string; card_id?: string;
registered_at?: string | null; registered_at?: string | null;
@ -210,7 +216,8 @@ export interface KIO {
building?: string | null; building?: string | null;
entrance?: string | null; entrance?: string | null;
floor?: string | null; floor?: string | null;
coords?: [number, number] | null; intercom_code?: string | null;
coords?: Coords | null;
incident_type?: IncidentType | null; incident_type?: IncidentType | null;
description?: string | null; description?: string | null;
victims_count?: number | null; victims_count?: number | null;
@ -283,6 +290,7 @@ export type PatchSource = "auto" | "operator";
export interface PoliceDetails { export interface PoliceDetails {
offence_kind?: string | null; offence_kind?: string | null;
suspects?: string | null; suspects?: string | null;
suspect_fled?: boolean | null;
vehicle?: string | null; vehicle?: string | null;
} }
@ -342,8 +350,7 @@ export interface SessionEnded {
reason: CallEndReason; reason: CallEndReason;
} }
/** Режим сессии. Меняет доступность подсказок и протоколирование, /** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
но не поведение звонящего (docs/product/MODES.md). */
export type SessionMode = "training" | "exam" | "self"; export type SessionMode = "training" | "exam" | "self";
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */ /** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
@ -377,6 +384,7 @@ export interface SessionSnapshot {
trainee_name?: string | null; trainee_name?: string | null;
started_at?: string | null; started_at?: string | null;
kio: KIO; kio: KIO;
required_fields?: Array<string>;
transcript: Array<TranscriptEntry>; transcript: Array<TranscriptEntry>;
timers: Array<TimerSnapshot>; timers: Array<TimerSnapshot>;
hints_used?: number; hints_used?: number;