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:
parent
5844b374ad
commit
59d5a6a26d
5 changed files with 106 additions and 17 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue