lct-hack/frontend/src/shared/api/http.ts
Ivan Gerasimov 7212251112 feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.

- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
  1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
  читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
  список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
  газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
  и пересчитываются при каждой правке признаков; добавленная вручную служба
  не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
  dds_choice больше не считается — список оповещения производен от признаков,
  и штрафовать за него отдельно значит наказать дважды за одну ошибку.

Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.

113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00

139 lines
4.1 KiB
TypeScript

// HTTP-клиент и хуки. Типы ответов HTTP в generated.ts пока не попадают —
// там только события WS; здесь описаны руками по docs/arch/CONTRACT.md#http-api.
import { useMutation, useQuery } from "@tanstack/react-query";
import type { Level, SessionMode } from "@/shared/types/generated";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
) {
super(`${status} ${code}`);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body.detail ?? response.statusText);
}
return response.json() as Promise<T>;
}
export interface Health {
status: string;
models_ready: boolean;
embeddings_ready: boolean;
scenarios_loaded: number;
offline: boolean;
}
export interface ScenarioSummary {
id: string;
title: string;
type: string;
level: Level;
topics: string[];
modes: SessionMode[];
dds: string | null;
}
export interface SessionInfo {
session_id: string;
scenario_id: string;
mode: SessionMode;
attempt: number;
trainee_id: string | null;
group_id: string | null;
}
export const useHealth = () =>
useQuery({ queryKey: ["health"], queryFn: () => request<Health>("/api/health"), refetchInterval: 5_000 });
export const useScenarios = () =>
useQuery({ queryKey: ["scenarios"], queryFn: () => request<ScenarioSummary[]>("/api/scenarios") });
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
const query = new URLSearchParams(
Object.entries(params).filter(([, value]) => value) as [string, string][],
).toString();
return useQuery({
queryKey: ["sessions", query],
queryFn: () => request<SessionInfo[]>(`/api/sessions${query ? `?${query}` : ""}`),
refetchInterval: 15_000,
});
};
export interface TraineeInfo {
id: string;
name: string;
group: string | null;
}
export interface Attempt {
session_id: string;
scenario_id: string;
mode: SessionMode;
attempt: number;
created_at: string;
score: number | null;
interview_ms: number | null;
facts_got: number | null;
facts_required: number | null;
hints: number | null;
codes: Record<string, number>;
}
export interface Profile {
trainee: TraineeInfo;
attempts: Attempt[];
competencies: Record<string, number>;
deltas: {
scenario_id: string;
from_attempt: number;
to_attempt: number;
score: number | null;
interview_ms: number | null;
facts_got: number | null;
}[];
hints_total: number;
}
/** Значения следующего признака происшествия при уже выбранных (ЕКП). */
export interface SignsPage {
level: number;
values: string[];
incident: { code: string; type: string; notify: string[] } | null;
}
export function useSigns(prefix: string[]) {
const query = prefix.map((sign) => `prefix=${encodeURIComponent(sign)}`).join("&");
return useQuery({
queryKey: ["ekp-signs", prefix],
// Справочник не меняется между занятиями — перезапрашивать незачем.
staleTime: Infinity,
queryFn: () => request<SignsPage>(`/api/ekp/signs${query ? `?${query}` : ""}`),
});
}
export const useTrainees = () =>
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
export const useProfile = (traineeId: string | null) =>
useQuery({
queryKey: ["profile", traineeId],
queryFn: () => request<Profile>(`/api/trainees/${traineeId}/profile`),
enabled: Boolean(traineeId),
});
export const useCreateSession = () =>
useMutation({
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
});