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;
}