diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 0fb797e..46b92b8 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -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 diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py index 8b9adb4..db3efad 100644 --- a/backend/app/domain/kio.py +++ b/backend/app/domain/kio.py @@ -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 diff --git a/backend/scripts/export_types.py b/backend/scripts/export_types.py index 9dc2d24..8ede153 100644 --- a/backend/scripts/export_types.py +++ b/backend/scripts/export_types.py @@ -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"]) diff --git a/backend/tests/test_domain.py b/backend/tests/test_domain.py index a12b10e..e711998 100644 --- a/backend/tests/test_domain.py +++ b/backend/tests/test_domain.py @@ -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" diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts index 75e89cc..f3b05b0 100644 --- a/frontend/src/shared/types/generated.ts +++ b/frontend/src/shared/types/generated.ts @@ -30,12 +30,14 @@ export interface CallHangup { type: "call.hangup"; } +/** Обязательность полей приходит сценарием, а не моделью: без `required_fields` АРМ не может подсветить незаполненное обязательное поле, и курсант узнаёт о неполноте карточки только из разбора. */ export interface CallIncoming { type: "call.incoming"; scenario_id: string; caller_number: string; level: Level; mode: SessionMode; + required_fields?: Array; } export interface CallStarted { @@ -84,6 +86,7 @@ export interface CompetencyScore { value: number; } +/** Координаты одним представлением на весь контракт. Именованные поля, а не кортеж: в `[55.75, 37.61]` невозможно увидеть, где широта, а где долгота, и ошибка всплывёт на карте, а не в типах. */ export interface Coords { lat: number; lon: number; @@ -131,10 +134,13 @@ export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6"; export interface ErrorEvent { type: "error"; - code: string; + code: ErrorKind; 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` — то самое обоснование. */ export interface Finding { code: ErrorCode; @@ -158,6 +164,7 @@ export interface FireDetails { floors?: number | null; gasified?: boolean | null; people_inside?: boolean | null; + smoke_spread?: string | null; } /** В режиме `exam` сервер отвечает событием `error`. */ @@ -194,8 +201,7 @@ export interface InstructorNoteShown { author: string; } -/** Полная карточка. Наблюдателям уходит целиком (`kio.state`), -курсанту — дельтой (`kio.patch`). */ +/** Полная карточка. Наблюдателям уходит целиком (`kio.state`), курсанту — дельтой (`kio.patch`). */ export interface KIO { card_id?: string; registered_at?: string | null; @@ -210,7 +216,8 @@ export interface KIO { building?: string | null; entrance?: string | null; floor?: string | null; - coords?: [number, number] | null; + intercom_code?: string | null; + coords?: Coords | null; incident_type?: IncidentType | null; description?: string | null; victims_count?: number | null; @@ -283,6 +290,7 @@ export type PatchSource = "auto" | "operator"; export interface PoliceDetails { offence_kind?: string | null; suspects?: string | null; + suspect_fled?: boolean | null; vehicle?: string | null; } @@ -342,8 +350,7 @@ export interface SessionEnded { reason: CallEndReason; } -/** Режим сессии. Меняет доступность подсказок и протоколирование, -но не поведение звонящего (docs/product/MODES.md). */ +/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */ export type SessionMode = "training" | "exam" | "self"; /** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */ @@ -377,6 +384,7 @@ export interface SessionSnapshot { trainee_name?: string | null; started_at?: string | null; kio: KIO; + required_fields?: Array; transcript: Array; timers: Array; hints_used?: number;