Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -31,6 +31,7 @@ export interface Health {
|
|||
models_ready: boolean;
|
||||
embeddings_ready: boolean;
|
||||
scenarios_loaded: number;
|
||||
sessions_restored?: number;
|
||||
offline: boolean;
|
||||
demo_no_db?: boolean;
|
||||
}
|
||||
|
|
@ -42,10 +43,13 @@ export interface ScenarioSummary {
|
|||
level: Level;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
|
|
@ -78,6 +82,7 @@ export interface TraineeInfo {
|
|||
id: string;
|
||||
name: string;
|
||||
group: string | null;
|
||||
service: string | null;
|
||||
}
|
||||
|
||||
export interface Attempt {
|
||||
|
|
@ -92,6 +97,7 @@ export interface Attempt {
|
|||
facts_required: number | null;
|
||||
hints: number | null;
|
||||
codes: Record<string, number>;
|
||||
recording_available: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
|
@ -139,8 +145,12 @@ export function useSigns(prefix: string[], group: number | null = null) {
|
|||
});
|
||||
}
|
||||
|
||||
export const useTrainees = () =>
|
||||
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
|
||||
export const useTrainees = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["trainees"],
|
||||
queryFn: () => request<TraineeInfo[]>("/api/trainees"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export interface GroupInfo {
|
||||
id: string;
|
||||
|
|
@ -163,8 +173,19 @@ export interface GroupAnalytics {
|
|||
}[];
|
||||
}
|
||||
|
||||
export const useGroups = () =>
|
||||
useQuery({ queryKey: ["groups"], queryFn: () => request<GroupInfo[]>("/api/groups") });
|
||||
export interface GroupInsight {
|
||||
summary: string;
|
||||
priorities: string[];
|
||||
source: "local_qwen";
|
||||
personal_data_sent: false;
|
||||
}
|
||||
|
||||
export const useGroups = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: ["groups"],
|
||||
queryFn: () => request<GroupInfo[]>("/api/groups"),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export function useCreateGroup() {
|
||||
const cache = useQueryClient();
|
||||
|
|
@ -196,6 +217,14 @@ export const useGroupAnalytics = (groupId: string | null) =>
|
|||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
export function useGroupInsight() {
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => request<GroupInsight>(
|
||||
`/api/groups/${groupId}/analytics/insight`, { method: "POST" },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export const useProfile = (traineeId: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["profile", traineeId],
|
||||
|
|
@ -208,3 +237,121 @@ export const useCreateSession = () =>
|
|||
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
||||
request<SessionInfo>("/api/sessions", { method: "POST", body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
export interface LearningMaterial {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
level: Level;
|
||||
kind: "text" | "file";
|
||||
body: string;
|
||||
scenario_id: string | null;
|
||||
file_name: string | null;
|
||||
media_type: string | null;
|
||||
file_size: number;
|
||||
file_sha256: string | null;
|
||||
active: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
assigned_at: string | null;
|
||||
completed_at: string | null;
|
||||
assignment_count: number;
|
||||
}
|
||||
|
||||
export interface MaterialCreateInput {
|
||||
title: string;
|
||||
description: string;
|
||||
level: Level;
|
||||
kind: "text" | "file";
|
||||
body: string;
|
||||
scenario_id: string | null;
|
||||
file_name?: string;
|
||||
media_type?: string;
|
||||
content_base64?: string;
|
||||
}
|
||||
|
||||
export const useMaterials = (includeArchived = false) => useQuery({
|
||||
queryKey: ["materials", includeArchived],
|
||||
queryFn: () => request<LearningMaterial[]>(
|
||||
`/api/materials${includeArchived ? "?include_archived=true" : ""}`,
|
||||
),
|
||||
});
|
||||
|
||||
function invalidateMaterials(cache: ReturnType<typeof useQueryClient>) {
|
||||
return cache.invalidateQueries({ queryKey: ["materials"] });
|
||||
}
|
||||
|
||||
export function useCreateMaterial() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: MaterialCreateInput) => request<LearningMaterial>("/api/materials", {
|
||||
method: "POST", body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMaterial() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: Partial<LearningMaterial> }) =>
|
||||
request<LearningMaterial>(`/api/materials/${id}`, {
|
||||
method: "PATCH", body: JSON.stringify(patch),
|
||||
}),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveMaterial() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignMaterial() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ materialId, traineeId }: { materialId: string; traineeId: string }) =>
|
||||
request<LearningMaterial>(`/api/materials/${materialId}/assign/${traineeId}`, {
|
||||
method: "PUT",
|
||||
}),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignMaterialGroup() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ materialId, groupId }: { materialId: string; groupId: string }) =>
|
||||
request<{ material_id: string; assigned: number }>(
|
||||
`/api/materials/${materialId}/assign-group/${groupId}`, { method: "PUT" },
|
||||
),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompleteMaterial() {
|
||||
const cache = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => request<LearningMaterial>(`/api/materials/${id}/complete`, {
|
||||
method: "POST",
|
||||
}),
|
||||
onSuccess: () => void invalidateMaterials(cache),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartMaterialPractice() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => request<{
|
||||
session_id: string;
|
||||
scenario_id: string;
|
||||
mode: "self";
|
||||
exercise: "card";
|
||||
path: string;
|
||||
}>(`/api/materials/${id}/start`, { method: "POST" }),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,25 +15,33 @@ import type {
|
|||
|
||||
export type ChannelStatus = "connecting" | "open" | "reconnecting" | "closed";
|
||||
|
||||
interface ChannelOptions<In> {
|
||||
interface ChannelOptions<In, Out> {
|
||||
onEvent?: (event: In) => void;
|
||||
/** Бинарный кадр: звук звонящего, без обёртки JSON. */
|
||||
onBinary?: (frame: ArrayBuffer) => void;
|
||||
onStatus?: (status: ChannelStatus) => void;
|
||||
/** Ключ безопасного идемпотентного события, которое можно повторить после
|
||||
* краткого разрыва. null запрещает буферизацию команды. */
|
||||
queueKey?: (event: Out) => string | null;
|
||||
}
|
||||
|
||||
const RECONNECT_MIN_MS = 500;
|
||||
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 }
|
||||
|
||||
export class Channel<In extends { type: string }, Out extends { type: string }> {
|
||||
private socket: WebSocket | null = null;
|
||||
private attempt = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUs = false;
|
||||
private buffered: Buffered<Out>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly options: ChannelOptions<In> = {},
|
||||
private readonly options: ChannelOptions<In, Out> = {},
|
||||
) {}
|
||||
|
||||
connect(): this {
|
||||
|
|
@ -51,6 +59,10 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
socket.onopen = () => {
|
||||
this.attempt = 0;
|
||||
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));
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
if (typeof message.data !== "string") {
|
||||
|
|
@ -78,11 +90,32 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
return this.socket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
// Пока соединения нет, события не копятся: истина — на сервере, и после
|
||||
// переподключения интерфейс получит актуальное состояние, а не очередь старых правок.
|
||||
send(event: Out): boolean {
|
||||
if (!this.isOpen) return false;
|
||||
this.socket!.send(JSON.stringify(event));
|
||||
if (this.isOpen) {
|
||||
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);
|
||||
if (existing) {
|
||||
// Частые kio.patch объединяем: последнее значение каждого поля важнее,
|
||||
// но правки разных полей не должны затереть друг друга.
|
||||
if (existing.event.type === "kio.patch" && event.type === "kio.patch") {
|
||||
const previousPatch = existing.event as unknown as { fields: Record<string, unknown> };
|
||||
const nextPatch = event as unknown as { fields: Record<string, unknown> };
|
||||
existing.event = {
|
||||
...existing.event,
|
||||
fields: { ...previousPatch.fields, ...nextPatch.fields },
|
||||
} as Out;
|
||||
} else {
|
||||
existing.event = event;
|
||||
}
|
||||
existing.at = Date.now();
|
||||
} else {
|
||||
this.buffered.push({ key, event, at: Date.now() });
|
||||
if (this.buffered.length > BUFFER_MAX_ITEMS) this.buffered.shift();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -96,22 +129,36 @@ export class Channel<In extends { type: string }, Out extends { type: string }>
|
|||
this.closedByUs = true;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.socket?.close();
|
||||
this.buffered = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const callChannel = (sessionId: string, options?: ChannelOptions<ServerToTrainee>) =>
|
||||
new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, options);
|
||||
export const callChannel = (
|
||||
sessionId: string,
|
||||
options?: ChannelOptions<ServerToTrainee, TraineeToServer>,
|
||||
) => new Channel<ServerToTrainee, TraineeToServer>(`/ws/call/${sessionId}`, {
|
||||
...options,
|
||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||
});
|
||||
|
||||
/** Наблюдатель: отправлять нечего и нельзя — `Out` равен `never`. */
|
||||
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver>) =>
|
||||
export const observeChannel = (sessionId: string, options?: ChannelOptions<ServerToObserver, never>) =>
|
||||
new Channel<ServerToObserver, never>(`/ws/observe/${sessionId}`, options);
|
||||
|
||||
/** Станция ДДС: только JSON, аудио здесь нет вообще. */
|
||||
export const stationChannel = (
|
||||
sessionId: string,
|
||||
role: string,
|
||||
options?: ChannelOptions<ServerToStation>,
|
||||
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, options);
|
||||
options?: ChannelOptions<ServerToStation, StationToServer>,
|
||||
) => new Channel<ServerToStation, StationToServer>(`/ws/station/${sessionId}?role=${role}`, {
|
||||
...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;
|
||||
},
|
||||
});
|
||||
|
||||
/** Преподаватель: только передача, входящих в этом канале нет. */
|
||||
export const controlChannel = (sessionId: string, options?: { onStatus?: (status: ChannelStatus) => void }) =>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,12 @@ export interface CardNext {
|
|||
card_id: string;
|
||||
}
|
||||
|
||||
/** Открыть одну из уже выданных карточек, не меняя её таймер. */
|
||||
export interface CardOpen {
|
||||
type: "card.open";
|
||||
card_id: string;
|
||||
}
|
||||
|
||||
/** Снимок КИО. После передачи не меняется — оператор не дописывает задним числом. */
|
||||
export interface CardReceived {
|
||||
type: "card.received";
|
||||
|
|
@ -171,6 +177,25 @@ export interface DdsDispatch {
|
|||
service?: DDSCode | null;
|
||||
}
|
||||
|
||||
/** Строка общей очереди ДДС; таймер каждой карточки идёт независимо. */
|
||||
export interface DdsQueueCard {
|
||||
card_id: string;
|
||||
scenario_id: string;
|
||||
title: string;
|
||||
address?: string | null;
|
||||
description?: string | null;
|
||||
incident_type?: string | null;
|
||||
victims_count?: number | null;
|
||||
received_at: string;
|
||||
managed_service?: string | null;
|
||||
service_status: ServiceStatus;
|
||||
card_status: CardStatus;
|
||||
elapsed_ms: number;
|
||||
limit_ms: number;
|
||||
timer_stopped: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** `immediate` — только для обрыва связи: рвёт TTS на полуслове. */
|
||||
export type DirectiveMode = "next_turn" | "immediate";
|
||||
|
||||
|
|
@ -272,6 +297,7 @@ export interface KIO {
|
|||
caller_number?: string | null;
|
||||
caller_name?: string | null;
|
||||
caller_contact?: string | null;
|
||||
phone_on_scene?: string | null;
|
||||
language?: string;
|
||||
okato?: string | null;
|
||||
address?: string | null;
|
||||
|
|
@ -322,6 +348,14 @@ export interface KioState {
|
|||
kio: KIO;
|
||||
}
|
||||
|
||||
/** Настраиваемые преподавателем условия именно этого занятия. Нормативы ГОСТ для приёма вызова сюда не входят. Меняется учебный лимит решения ДДС и порог успешности, которые ТЗ прямо отдаёт преподавателю. */
|
||||
export interface LessonCriteria {
|
||||
decision_time_limit_seconds?: number;
|
||||
allowed_errors?: number;
|
||||
require_correct_grammar?: boolean;
|
||||
score_weights?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Уровень сложности сценария. */
|
||||
export type Level = "L1" | "L2" | "L3";
|
||||
|
||||
|
|
@ -359,10 +393,51 @@ export type Outcome = "card" | "consultation" | "transfer_region";
|
|||
/** `auto` — сервер распознал факт из речи, `operator` — эхо правки курсанта. */
|
||||
export type PatchSource = "auto" | "operator";
|
||||
|
||||
/** Что диспетчер передал старшему группы при первом исходящем звонке. */
|
||||
export interface PhoneBrief {
|
||||
type: "phone.brief";
|
||||
address: string;
|
||||
incident: string;
|
||||
request?: string;
|
||||
}
|
||||
|
||||
export interface PhoneCallPending {
|
||||
service: string;
|
||||
crew: string;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
/** Запрос диспетчера об обстановке после предыдущего доклада. */
|
||||
export interface PhoneCheck {
|
||||
type: "phone.check";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PhoneDial {
|
||||
type: "phone.dial";
|
||||
}
|
||||
|
||||
export interface PhoneHangup {
|
||||
type: "phone.hangup";
|
||||
}
|
||||
|
||||
export interface PhoneLine {
|
||||
type: "phone.line";
|
||||
service: string;
|
||||
crew: string;
|
||||
speaker: "dispatcher" | "crew";
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface PhoneLineRecord {
|
||||
service: string;
|
||||
crew: string;
|
||||
speaker: string;
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface PhoneReport {
|
||||
type: "phone.report";
|
||||
service: string;
|
||||
|
|
@ -420,12 +495,16 @@ export interface ScenarioStart {
|
|||
type: "scenario.start";
|
||||
scenario_id: string;
|
||||
scenario_ids?: Array<string> | null;
|
||||
dds_arrival_interval_seconds?: number;
|
||||
dds_max_waiting?: number;
|
||||
trainee: string;
|
||||
trainee_id?: string | null;
|
||||
dds_service?: string | null;
|
||||
group_id?: string | null;
|
||||
mode: SessionMode;
|
||||
exercise?: Exercise;
|
||||
handoff_to_dds?: boolean;
|
||||
criteria?: LessonCriteria;
|
||||
}
|
||||
|
||||
/** Коррекция оценки. Автооценка сохраняется рядом. */
|
||||
|
|
@ -486,6 +565,9 @@ export interface SessionReport {
|
|||
scenario_id: string;
|
||||
mode: SessionMode;
|
||||
attempt?: number;
|
||||
criteria: LessonCriteria;
|
||||
failed_metrics: number;
|
||||
passed: boolean;
|
||||
transcript: Array<TranscriptEntry>;
|
||||
findings: Array<Finding>;
|
||||
metrics: Array<Metric>;
|
||||
|
|
@ -512,6 +594,7 @@ export interface SessionSnapshot {
|
|||
level: Level;
|
||||
mode: SessionMode;
|
||||
exercise?: Exercise;
|
||||
criteria?: LessonCriteria;
|
||||
trainee_name?: string | null;
|
||||
started_at?: string | null;
|
||||
kio: KIO;
|
||||
|
|
@ -535,6 +618,8 @@ export interface StationFinish {
|
|||
/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
|
||||
export interface StationSnapshot {
|
||||
services: Array<string>;
|
||||
recipient_services?: Array<string>;
|
||||
managed_service?: string | null;
|
||||
statuses: Record<string, ServiceStatus>;
|
||||
available: Record<string, Array<ServiceStatus>>;
|
||||
card: CardStatus;
|
||||
|
|
@ -542,11 +627,17 @@ export interface StationSnapshot {
|
|||
crew_options?: Array<string>;
|
||||
crew_selected?: string | null;
|
||||
phone_reports?: Array<PhoneReportRecord>;
|
||||
phone_lines?: Array<PhoneLineRecord>;
|
||||
phone_pending?: PhoneCallPending | null;
|
||||
card_id?: string | null;
|
||||
card_index?: number;
|
||||
card_total?: number;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
|
||||
|
|
@ -652,9 +743,9 @@ export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | T
|
|||
|
||||
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
|
||||
|
||||
export type ServerToStation = CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
|
||||
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent;
|
||||
|
||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish | CardReply | CardNext | ZoneDecision | CrewDispatched | CrewArrived;
|
||||
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
|
||||
|
||||
export type EventName =
|
||||
"bg.start"
|
||||
|
|
@ -671,6 +762,7 @@ export type EventName =
|
|||
| "card.bounce"
|
||||
| "card.briefing"
|
||||
| "card.next"
|
||||
| "card.open"
|
||||
| "card.received"
|
||||
| "card.reply"
|
||||
| "card.status"
|
||||
|
|
@ -689,7 +781,11 @@ export type EventName =
|
|||
| "kio.patch"
|
||||
| "kio.state"
|
||||
| "mode.set"
|
||||
| "phone.brief"
|
||||
| "phone.check"
|
||||
| "phone.dial"
|
||||
| "phone.hangup"
|
||||
| "phone.line"
|
||||
| "phone.report"
|
||||
| "reference.play"
|
||||
| "reference.started"
|
||||
|
|
|
|||
Loading…
Reference in a new issue