Complete training workflow and acceptance hardening

This commit is contained in:
andreysk0304 2026-09-26 18:12:27 +03:00 • committed by gglamer
commit 7237265833
243 changed files with 17014 additions and 1500 deletions

View file

@ -3,7 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Level, SessionMode } from "@/shared/types/generated";
import type { Level, SessionMode, StationSnapshot } from "@/shared/types/generated";
export class ApiError extends Error {
constructor(
@ -21,7 +21,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(response.status, body.detail ?? response.statusText);
const detail = body.detail;
const message = typeof detail === "string"
? detail
: Array.isArray(detail)
? detail.map((item: { loc?: unknown[]; msg?: string }) => {
const path = item.loc?.slice(1).join(".");
return `${path ? `${path}: ` : ""}${item.msg ?? "некорректное значение"}`;
}).join("; ")
: response.statusText;
throw new ApiError(response.status, message);
}
return response.json() as Promise<T>;
}
@ -39,17 +48,36 @@ export interface Health {
export interface ScenarioSummary {
id: string;
title: string;
type: string;
outcome?: "card" | "consultation" | "transfer_region";
type?: string;
level: Level;
topics: string[];
topics?: string[];
modes: SessionMode[];
signs: string[];
incident_code: string | null;
dds: string | null;
ticket: number | null;
position: number | null;
ekp_group: number | null;
can_manage: boolean;
signs?: string[];
incident_code?: string | null;
dds?: string | null;
ticket?: number | null;
position?: number | null;
ekp_group?: number | null;
can_manage?: boolean;
source?: "system" | "trainee";
}
export interface ScenarioSubmission {
id: string;
author_name: string;
title: string;
incident_type: string;
level: Level;
description: string;
address: string;
victims: number | null;
kio: Record<string, unknown> | null;
status: "pending" | "approved" | "rejected";
review_comment: string;
scenario_id: string | null;
created_at: string;
reviewed_at: string | null;
}
export interface SessionInfo {
@ -59,13 +87,75 @@ export interface SessionInfo {
attempt: number;
trainee_id: string | null;
group_id: string | null;
started_at: string | null;
ended_at: string | null;
end_reason: string | null;
}
export interface ActiveSessionInfo {
session_id: string;
trainee_name: string | null;
scenario_id: string;
scenario_title: string;
mode: SessionMode;
exercise: "call" | "card" | "dds";
started_at: string | null;
elapsed_seconds: number;
dds_card_total: number;
dds_open_cards: number;
dds_overdue_cards: number;
dds_work_overdue_cards: number;
dds_statuses: Record<string, string>;
dds_snapshot: StationSnapshot | null;
}
export const useActiveSessions = () =>
useQuery({
queryKey: ["sessions", "active"],
queryFn: () => request<ActiveSessionInfo[]>("/api/sessions/active"),
refetchInterval: 2_000,
});
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 useScenarios = (enabled = true, roleScope = "instructor") =>
useQuery({
queryKey: ["scenarios", roleScope],
queryFn: () => request<ScenarioSummary[]>("/api/scenarios"),
enabled,
});
export const useScenarioSubmissions = (enabled = true) =>
useQuery({
queryKey: ["scenario-submissions"],
queryFn: () => request<ScenarioSubmission[]>("/api/scenario-submissions"),
enabled,
});
export const useCreateScenarioSubmission = () => {
const client = useQueryClient();
return useMutation({
mutationFn: (payload: { title: string; level: Level; kio: Record<string, unknown> }) => request<ScenarioSubmission>("/api/scenario-submissions", {
method: "POST", body: JSON.stringify(payload),
}),
onSuccess: () => client.invalidateQueries({ queryKey: ["scenario-submissions"] }),
});
};
export const useReviewScenarioSubmission = () => {
const client = useQueryClient();
return useMutation({
mutationFn: (payload: { id: string; decision: "approve" | "reject"; comment?: string }) =>
request<ScenarioSubmission>(`/api/scenario-submissions/${payload.id}/review`, {
method: "POST", body: JSON.stringify({ decision: payload.decision, comment: payload.comment ?? "" }),
}),
onSuccess: () => {
client.invalidateQueries({ queryKey: ["scenario-submissions"] });
client.invalidateQueries({ queryKey: ["scenarios"] });
},
});
};
export const useSessions = (params: { trainee?: string; group?: string; mode?: SessionMode } = {}) => {
const query = new URLSearchParams(
@ -112,6 +202,12 @@ export interface Profile {
interview_ms: number | null;
facts_got: number | null;
}[];
recommendations: {
code: string;
title: string;
recommendation: string;
occurrences: number;
}[];
hints_total: number;
}

View file

@ -23,6 +23,8 @@ interface ChannelOptions<In, Out> {
/** Ключ безопасного идемпотентного события, которое можно повторить после
* краткого разрыва. null запрещает буферизацию команды. */
queueKey?: (event: Out) => string | null;
/** Подтверждение, что серверный durable snapshot уже содержит команду. */
isAcknowledged?: (incoming: In, pending: Out) => boolean;
}
const RECONNECT_MIN_MS = 500;
@ -30,7 +32,7 @@ const RECONNECT_MAX_MS = 5_000;
const BUFFER_MAX_AGE_MS = 35_000;
const BUFFER_MAX_ITEMS = 100;
interface Buffered<Out> { key: string; event: Out; at: number }
interface Buffered<Out> { key: string; commandId: string; event: Out; at: number }
export class Channel<In extends { type: string }, Out extends { type: string }> {
private socket: WebSocket | null = null;
@ -61,15 +63,25 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
this.options.onStatus?.("open");
const now = Date.now();
const pending = this.buffered.filter((item) => now - item.at <= BUFFER_MAX_AGE_MS);
this.buffered = [];
for (const item of pending) socket.send(JSON.stringify(item.event));
this.buffered = pending;
for (const item of pending) socket.send(this.encode(item));
};
socket.onmessage = (message) => {
if (typeof message.data !== "string") {
this.options.onBinary?.(message.data as ArrayBuffer);
return;
}
this.options.onEvent?.(JSON.parse(message.data) as In);
const event = JSON.parse(message.data) as In;
if (event.type === "command.ack" && typeof (event as { command_id?: unknown }).command_id === "string") {
const commandId = (event as unknown as { command_id: string }).command_id;
this.buffered = this.buffered.filter((item) => item.commandId !== commandId);
}
if (this.options.isAcknowledged) {
this.buffered = this.buffered.filter(
(item) => !this.options.isAcknowledged!(event, item.event),
);
}
this.options.onEvent?.(event);
};
socket.onclose = () => {
this.socket = null;
@ -91,13 +103,14 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
}
send(event: Out): boolean {
if (this.isOpen) {
const key = this.options.queueKey?.(event) ?? null;
if (!key) {
if (!this.isOpen) return false;
this.socket!.send(JSON.stringify(event));
return true;
}
const key = this.options.queueKey?.(event) ?? null;
if (!key) return false;
const existing = this.buffered.find((item) => item.key === key);
let pending: Buffered<Out>;
if (existing) {
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
// но правки разных полей не должны затереть друг друга.
@ -112,13 +125,24 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
existing.event = event;
}
existing.at = Date.now();
pending = existing;
} else {
this.buffered.push({ key, event, at: Date.now() });
pending = { key, commandId: crypto.randomUUID(), event, at: Date.now() };
this.buffered.push(pending);
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
}
// Keep a replay-safe command (for at most BUFFER_MAX_AGE_MS) until an event
// emitted after the server's durable checkpoint proves it was applied. A
// send() on an open socket is not itself an acknowledgement: the connection
// may die before the server receives it or the response reaches this browser.
if (this.isOpen) this.socket!.send(this.encode(pending));
return true;
}
private encode(item: Buffered<Out>): string {
return JSON.stringify({ ...item.event, _command_id: item.commandId });
}
sendBinary(frame: ArrayBuffer): boolean {
if (!this.isOpen) return false;
this.socket!.send(frame);
@ -139,6 +163,13 @@ export const callChannel = (
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
...options,
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
isAcknowledged: (incoming, pending) => {
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|| incoming.source !== "operator") return false;
return Object.entries(pending.fields).every(([key, value]) =>
sameJsonValue(value, incoming.fields[key]),
);
},
});
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
@ -154,12 +185,34 @@ export const stationChannel = (
...options,
queueKey: (event) => {
if (event.type === "card.reply") return `card.reply:${event.card_id}`;
if (event.type === "crew.select") return "crew.select";
if (event.type === "zone.decision") return "zone.decision";
return null;
// Non-replacement station actions are each their own replayable command.
// The server deduplicates by this ID inside its persisted session snapshot.
return `command:${crypto.randomUUID()}`;
},
isAcknowledged: (incoming, pending) => incoming.type === "station.state"
&& pending.type === "card.reply"
&& incoming.snapshot.card_id === pending.card_id
&& incoming.snapshot.reply_text === pending.text,
});
/** Преподаватель: только передача, входящих в этом канале нет. */
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
new Channel<never, InstructorToServer>(`/ws/control/${sessionId}`, options);
function sameJsonValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) return true;
if (Array.isArray(left) || Array.isArray(right)) {
return Array.isArray(left) && Array.isArray(right)
&& left.length === right.length
&& left.every((item, index) => sameJsonValue(item, right[index]));
}
if (left && right && typeof left === "object" && typeof right === "object") {
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const keys = Object.keys(leftRecord);
return keys.length === Object.keys(rightRecord).length
&& keys.every((key) => key in rightRecord
&& sameJsonValue(leftRecord[key], rightRecord[key]));
}
return false;
}

View file

@ -3,6 +3,17 @@
// Контракт: docs/arch/CONTRACT.md
export interface AICoaching {
status: "ready" | "unavailable" | "disabled" | "not_needed";
model?: string | null;
recommendations?: Array<AIRecommendation>;
}
export interface AIRecommendation {
metric_key: string;
text: string;
}
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
export interface BgStart {
type: "bg.start";
@ -63,11 +74,13 @@ export interface CallerUtterance {
text: string;
at: string;
mood: Mood;
source?: "local_llm" | "scenario";
}
/** Останавливает норматив `dds_ack` (≤ 30 с). */
export interface CardAck {
type: "card.ack";
comment: string;
}
/** Возврат на уточнение: неполнота КИО становится сорванным выездом. */
@ -122,6 +135,12 @@ export interface CardSubmit {
type: "card.submit";
}
/** Durable confirmation for a station command, safe to replay by ID. */
export interface CommandAck {
type: "command.ack";
command_id: string;
}
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@ -163,6 +182,14 @@ export interface DdsCardReport {
findings: Array<Finding>;
actions?: Array<Record<string, unknown>>;
duration_ms?: number;
title?: string | null;
address?: string | null;
description?: string | null;
incident_type?: string | null;
victims_count?: number | null;
received_at?: string | null;
managed_service?: string | null;
recipient_services?: Array<string>;
}
export interface DdsCardSummary {
@ -242,8 +269,8 @@ export interface Finding {
at?: string | null;
}
/** Кто выставил отметку. `judge` — единственный недетерминированный источник. */
export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "grammar" | "judge" | "instructor";
/** Уточняющие поля 01 — МЧС. */
export interface FireDetails {
@ -348,9 +375,11 @@ export interface KioState {
kio: KIO;
}
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Меняется учебный лимит решения ДДС и порог успешности, которые ТЗ прямо отдаёт преподавателю. */
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются учебные лимиты первичной реакции и полного цикла карточки ДДС, заполнения КИО, порог успешности и веса метрик; веса сценария остаются базовыми, настройки занятия могут их переопределить. */
export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -368,7 +397,7 @@ export interface MedicalDetails {
age?: number | null;
}
/** Метрика оценки: факт против норматива со ссылкой. Не балл, а обоснование. */
/** Факт против норматива со ссылкой; `credit` задаёт частичный вклад времени. */
export interface Metric {
key: string;
title: string;
@ -377,6 +406,7 @@ export interface Metric {
ref?: string | null;
passed: boolean;
weight?: number;
credit?: number | null;
}
export interface ModeSet {
@ -486,15 +516,11 @@ export type ResponseStatus = "registered" | "transferred" | "in_progress" | "clo
export type Role = "admin" | "instructor" | "trainee";
export interface ScenarioPublish {
type: "scenario.publish";
scenario_id: string;
}
export interface ScenarioStart {
type: "scenario.start";
scenario_id: string;
scenario_ids?: Array<string> | null;
random_scenario_ids?: Array<string> | null;
dds_arrival_interval_seconds?: number;
dds_max_waiting?: number;
trainee: string;
@ -564,6 +590,7 @@ export interface SessionReport {
session_id: string;
scenario_id: string;
mode: SessionMode;
exercise?: Exercise;
attempt?: number;
criteria: LessonCriteria;
failed_metrics: number;
@ -571,6 +598,7 @@ export interface SessionReport {
transcript: Array<TranscriptEntry>;
findings: Array<Finding>;
metrics: Array<Metric>;
ai_coaching?: AICoaching | null;
card_results?: Array<DdsCardReport>;
competencies: Array<CompetencyScore>;
reference_questions: Array<HintShown>;
@ -626,6 +654,7 @@ export interface StationSnapshot {
log: Array<StatusEntry>;
crew_options?: Array<string>;
crew_selected?: string | null;
zone_decision?: boolean | null;
phone_reports?: Array<PhoneReportRecord>;
phone_lines?: Array<PhoneLineRecord>;
phone_pending?: PhoneCallPending | null;
@ -635,9 +664,9 @@ export interface StationSnapshot {
reply_text?: string;
completed_cards?: Array<DdsCardSummary>;
queue_cards?: Array<DdsQueueCard>;
pending_cards_count: number;
next_arrival_in_seconds: number | null;
max_waiting_cards: number;
pending_cards_count?: number;
next_arrival_in_seconds?: number | null;
max_waiting_cards?: number;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@ -666,8 +695,19 @@ export interface SttPartial {
text: string;
}
export interface TextTurn {
type: "text.turn";
text: string;
}
export interface TextTurnAccepted {
type: "text.turn.accepted";
text: string;
at: string;
}
/** Коды одинаковы на бэке, фронте и в отчёте. */
export type TimerCode = "answer" | "interview" | "dds_notify" | "dds_ack" | "zone_check" | "callback" | "close";
export type TimerCode = "answer" | "interview" | "dds_notify" | "dds_ack" | "dds_work" | "card_fill" | "zone_check" | "callback" | "close";
/** Один таймер в событии `timer.tick`. */
export interface TimerSnapshot {
@ -735,15 +775,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
export type ServerToTrainee = CallIncoming | CardBriefing | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@ -767,6 +807,7 @@ export type EventName =
| "card.reply"
| "card.status"
| "card.submit"
| "command.ack"
| "crew.arrived"
| "crew.dispatched"
| "crew.select"
@ -789,7 +830,6 @@ export type EventName =
| "phone.report"
| "reference.play"
| "reference.started"
| "scenario.publish"
| "scenario.start"
| "score.override"
| "score.ready"
@ -801,6 +841,8 @@ export type EventName =
| "station.state"
| "stt.final"
| "stt.partial"
| "text.turn"
| "text.turn.accepted"
| "timer.tick"
| "transcript.append"
| "tts.begin"