2026-09-17 21:13:41 +03:00
|
|
|
|
// Звонок глазами курсанта: канал, микрофон, голос звонящего, перебивание.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Фронт не хранит состояние сессии как истину: всё, что здесь лежит, — это то,
|
|
|
|
|
|
// что прислал сервер (docs/arch/CONTRACT.md).
|
|
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
import { type ChannelStatus, callChannel } from "@/shared/api/ws";
|
|
|
|
|
|
import { Ambience } from "@/shared/audio/ambience";
|
|
|
|
|
|
import { type Capture, startCapture } from "@/shared/audio/capture";
|
|
|
|
|
|
import { EnergyGate } from "@/shared/audio/levels";
|
|
|
|
|
|
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
|
2026-09-21 17:40:54 +03:00
|
|
|
|
import { type CardState, applyPatch, applyState, edit, empty as emptyCard } from "@/features/kio-card/merge";
|
2026-09-17 21:24:34 +03:00
|
|
|
|
import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment";
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
import type {
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
Outcome,
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
CallIncoming,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
CardBriefing,
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
DDSCode,
|
|
|
|
|
|
ServerToTrainee,
|
|
|
|
|
|
SessionReport,
|
|
|
|
|
|
TimerSnapshot,
|
|
|
|
|
|
} from "@/shared/types/generated";
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
|
|
|
|
|
|
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
|
|
|
|
|
|
const PATCH_DEBOUNCE_MS = 300;
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
|
|
|
|
|
export interface Line {
|
|
|
|
|
|
speaker: "caller" | "operator";
|
|
|
|
|
|
text: string;
|
2026-09-26 18:12:27 +03:00
|
|
|
|
source?: "local_llm" | "scenario";
|
2026-09-17 21:13:41 +03:00
|
|
|
|
partial?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type CallPhase = "waiting" | "incoming" | "talking" | "ended";
|
|
|
|
|
|
|
|
|
|
|
|
export function useCall(sessionId: string | null) {
|
|
|
|
|
|
const [status, setStatus] = useState<ChannelStatus>("connecting");
|
|
|
|
|
|
const [phase, setPhase] = useState<CallPhase>("waiting");
|
|
|
|
|
|
const [incoming, setIncoming] = useState<CallIncoming | null>(null);
|
2026-09-21 17:40:54 +03:00
|
|
|
|
const [briefing, setBriefing] = useState<CardBriefing | null>(null);
|
|
|
|
|
|
const [cardSubmitted, setCardSubmitted] = useState(false);
|
2026-09-17 21:13:41 +03:00
|
|
|
|
const [lines, setLines] = useState<Line[]>([]);
|
|
|
|
|
|
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
|
|
|
|
|
|
const [callerSpeaking, setCallerSpeaking] = useState(false);
|
|
|
|
|
|
const [micOn, setMicOn] = useState(false);
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
const [card, setCard] = useState<CardState>(emptyCard);
|
2026-09-17 21:24:34 +03:00
|
|
|
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
|
|
|
|
|
const [selfAssessed, setSelfAssessed] = useState(false);
|
|
|
|
|
|
const [scoreReady, setScoreReady] = useState(false);
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
const [report, setReport] = useState<SessionReport | null>(null);
|
2026-09-21 17:40:54 +03:00
|
|
|
|
const [reportError, setReportError] = useState<string | null>(null);
|
2026-09-17 21:13:41 +03:00
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
|
|
|
|
|
|
const capture = useRef<Capture | null>(null);
|
|
|
|
|
|
const audio = useRef<{ context: AudioContext; playback: Playback; ambience: Ambience } | null>(null);
|
|
|
|
|
|
const gate = useRef(new EnergyGate());
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
const outbox = useRef<Record<string, unknown>>({});
|
|
|
|
|
|
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
2026-09-21 17:40:54 +03:00
|
|
|
|
const textMode = useRef(false);
|
2026-09-17 21:13:41 +03:00
|
|
|
|
|
|
|
|
|
|
const append = useCallback((line: Line) => {
|
|
|
|
|
|
setLines((prev) => {
|
|
|
|
|
|
const withoutPartial = prev.filter((item) => !item.partial);
|
|
|
|
|
|
return [...withoutPartial, line].slice(-50);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!sessionId) return;
|
|
|
|
|
|
const ch = callChannel(sessionId, {
|
2026-09-24 01:10:49 +03:00
|
|
|
|
onStatus: (next) => {
|
|
|
|
|
|
setStatus(next);
|
|
|
|
|
|
if (next === "open") {
|
|
|
|
|
|
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
|
|
|
|
|
|
// отмечено pending и не пришло эхом от сервера, повторяем после
|
|
|
|
|
|
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
|
|
|
|
|
|
queueMicrotask(() => setCard((current) => {
|
|
|
|
|
|
if (Object.keys(current.pending).length) {
|
|
|
|
|
|
channel.current?.send({ type: "kio.patch", fields: current.pending });
|
|
|
|
|
|
}
|
|
|
|
|
|
return current;
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-09-17 21:13:41 +03:00
|
|
|
|
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
|
|
|
|
|
onEvent: (event: ServerToTrainee) => {
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if (event.type === "text.turn.accepted") {
|
|
|
|
|
|
append({ speaker: "operator", text: event.text });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if (event.type === "card.briefing") {
|
|
|
|
|
|
const briefing = event;
|
|
|
|
|
|
textMode.current = true;
|
|
|
|
|
|
setBriefing(briefing);
|
|
|
|
|
|
setIncoming(null);
|
|
|
|
|
|
setCard(applyState(emptyCard, { ...briefing.card }));
|
|
|
|
|
|
setCardSubmitted(false);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-17 21:13:41 +03:00
|
|
|
|
switch (event.type) {
|
|
|
|
|
|
case "call.incoming":
|
|
|
|
|
|
setIncoming(event);
|
|
|
|
|
|
setPhase("incoming");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "call.started":
|
|
|
|
|
|
setPhase("talking");
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "stt.partial":
|
|
|
|
|
|
if (event.text) append({ speaker: "operator", text: event.text, partial: true });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "stt.final":
|
|
|
|
|
|
append({ speaker: "operator", text: event.text });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "caller.utterance":
|
2026-09-26 18:12:27 +03:00
|
|
|
|
append({ speaker: "caller", text: event.text, source: event.source });
|
2026-09-17 21:13:41 +03:00
|
|
|
|
break;
|
|
|
|
|
|
case "tts.begin":
|
|
|
|
|
|
setCallerSpeaking(true);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "tts.end":
|
|
|
|
|
|
setCallerSpeaking(false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "tts.cancel":
|
|
|
|
|
|
// Сервер подтвердил перебивание: выбросить всё, что не доиграло.
|
|
|
|
|
|
audio.current?.playback.flush();
|
|
|
|
|
|
setCallerSpeaking(false);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "bg.start":
|
|
|
|
|
|
void audio.current?.ambience.start(event.loop, event.gain_db);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "bg.stop":
|
|
|
|
|
|
audio.current?.ambience.stop();
|
|
|
|
|
|
break;
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
case "kio.patch":
|
|
|
|
|
|
setCard((prev) => applyPatch(prev, event.fields, event.source));
|
|
|
|
|
|
break;
|
2026-09-17 21:13:41 +03:00
|
|
|
|
case "timer.tick":
|
|
|
|
|
|
setTimers(event.timers);
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "call.ended":
|
|
|
|
|
|
setPhase("ended");
|
|
|
|
|
|
audio.current?.ambience.stop();
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if (textMode.current) { setCardSubmitted(true); break; }
|
2026-09-17 21:24:34 +03:00
|
|
|
|
// Чек-лист открывается только после звонка: во время него это
|
|
|
|
|
|
// содержимое подсказок.
|
|
|
|
|
|
void fetch(`/api/sessions/${sessionId}/checklist`)
|
|
|
|
|
|
.then((response) => (response.ok ? response.json() : []))
|
|
|
|
|
|
.then(setChecklist)
|
|
|
|
|
|
.catch(() => setChecklist([]));
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "score.ready":
|
|
|
|
|
|
setScoreReady(true);
|
2026-09-21 17:40:54 +03:00
|
|
|
|
setReportError(null);
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
void fetch(`/api/sessions/${sessionId}/report`)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
.then((response) => {
|
|
|
|
|
|
if (response.status === 403) throw new Error("Отчёт недоступен: занятие привязано к другому обучающемуся.");
|
|
|
|
|
|
if (response.status === 409) throw new Error("Оценка ещё не готова. Обновите страницу позже.");
|
|
|
|
|
|
if (!response.ok) throw new Error(`Не удалось загрузить отчёт (HTTP ${response.status}).`);
|
|
|
|
|
|
return response.json();
|
|
|
|
|
|
})
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
.then(setReport)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
.catch((cause: unknown) => setReportError(cause instanceof Error ? cause.message : "Не удалось загрузить отчёт."));
|
2026-09-17 21:13:41 +03:00
|
|
|
|
break;
|
|
|
|
|
|
case "error":
|
|
|
|
|
|
setError(event.message);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
}).connect();
|
|
|
|
|
|
channel.current = ch;
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
ch.close();
|
|
|
|
|
|
void capture.current?.stop();
|
|
|
|
|
|
audio.current?.ambience.stop();
|
|
|
|
|
|
void audio.current?.context.close();
|
|
|
|
|
|
audio.current = null;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [sessionId, append]);
|
|
|
|
|
|
|
|
|
|
|
|
const answer = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
|
|
|
|
|
|
const context = new AudioContext({ sampleRate: CALLER_RATE });
|
|
|
|
|
|
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
|
|
|
|
|
|
|
|
|
|
|
|
capture.current = await startCapture((frame) => {
|
|
|
|
|
|
const playback = audio.current?.playback;
|
|
|
|
|
|
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
|
|
|
|
|
|
if (gate.current.push(frame) && playback?.speaking) {
|
|
|
|
|
|
playback.flush();
|
|
|
|
|
|
setCallerSpeaking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
channel.current?.sendBinary(frame);
|
|
|
|
|
|
});
|
|
|
|
|
|
setMicOn(true);
|
|
|
|
|
|
channel.current?.send({ type: "call.answer" });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const hangup = useCallback(() => {
|
|
|
|
|
|
channel.current?.send({ type: "call.hangup" });
|
|
|
|
|
|
void capture.current?.stop();
|
|
|
|
|
|
capture.current = null;
|
|
|
|
|
|
setMicOn(false);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
|
|
|
|
|
|
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
const patchKio = useCallback((path: string, value: unknown) => {
|
|
|
|
|
|
setCard((prev) => edit(prev, path, value));
|
|
|
|
|
|
outbox.current[path] = value;
|
|
|
|
|
|
if (patchTimer.current) clearTimeout(patchTimer.current);
|
|
|
|
|
|
patchTimer.current = setTimeout(() => {
|
|
|
|
|
|
const fields = outbox.current;
|
|
|
|
|
|
outbox.current = {};
|
|
|
|
|
|
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
|
|
|
|
|
|
}, PATCH_DEBOUNCE_MS);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
const dispatch = useCallback((service?: DDSCode) => {
|
|
|
|
|
|
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
const sendText = useCallback((text: string) => {
|
|
|
|
|
|
const clean = text.trim();
|
|
|
|
|
|
if (!clean || clean.length > 1000) return false;
|
|
|
|
|
|
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
const submitCard = useCallback(() => {
|
|
|
|
|
|
if (patchTimer.current) clearTimeout(patchTimer.current);
|
|
|
|
|
|
patchTimer.current = null;
|
|
|
|
|
|
const fields = outbox.current;
|
|
|
|
|
|
outbox.current = {};
|
|
|
|
|
|
if (Object.keys(fields).length && !channel.current?.send({ type: "kio.patch", fields })) {
|
|
|
|
|
|
setError("Канал отключён: карточка не отправлена.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (channel.current?.send({ type: "card.submit" })) {
|
|
|
|
|
|
setCardSubmitted(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError("Канал отключён: карточка не отправлена.");
|
|
|
|
|
|
}
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
/** Закрыть вызов не карточкой: справкой или передачей в другой регион.
|
|
|
|
|
|
* Отдельное действие, а не «положил трубку»: система должна отличить
|
|
|
|
|
|
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
|
|
|
|
|
|
const resolve = useCallback((outcome: Outcome, comment: string) => {
|
|
|
|
|
|
channel.current?.send({ type: "call.resolve", outcome, comment });
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
|
|
|
|
|
|
channel.current?.send({ type: "self_assessment.submit", missed, comment });
|
|
|
|
|
|
setSelfAssessed(true);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
return {
|
feat: исход вызова — не каждый звонок заканчивается карточкой (lct-36)
Весь продукт стоял на допущении «звонок → карточка → выезд». В билетах
заказчика оно нарушается намеренно: «поругался с продавцом Мегафон» — справка,
а вызов из Волгоградской области передаётся в систему-112 другого субъекта.
Курсант, заведший карточку на такой вызов, занял расчёт зря, и прежняя оценка
этого не видела — наоборот, награждала за полноту заполнения.
- Outcome в домене, поле outcome в сценарии, событие call.resolve у курсанта
и две кнопки на АРМ. Отдельное действие, а не «положил трубку»: система
должна отличить осознанное решение от брошенного вызова.
- Метрика outcome (E2, вес 3 — лишний выезд дороже неточного признака).
- Там, где карточка не заводится, метрики карточки не считаются вовсе.
Полнота опроса считается: передать вызов не значит не опрашивать.
- call.resolve останавливает норматив опроса, как и передача карточки.
Попутно (lct-34): библиотека стала вложенной — scenarios/tickets/, поля
ticket и position, чек-листы для медицины, полиции и ЖКХ. Перенесён билет 1
целиком: три вызова, три классификатора, третий — из другого региона.
Найдено по ходу: модификаторы списка оповещения не были подключены ни к чему.
В классификаторе скорая добавляется к массовой драке признаком «пострадавшие»,
но взять его было неоткуда. Теперь модификаторы берутся из карточки —
victims_count, life_threat, evacuation_needed, fire.gasified, — и список
оповещения пересобирается при правке любого из этих полей.
161 тест зелёный (13 новых), make typecheck чистый.
2026-09-20 08:33:30 +03:00
|
|
|
|
resolve,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
|
|
|
|
|
|
checklist, selfAssessed, scoreReady, report, reportError,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
|
lct-10: карточка КИО — форма, слияние правок, read-only
Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.
Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.
Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.
Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
2026-09-17 21:16:51 +03:00
|
|
|
|
};
|
2026-09-17 21:13:41 +03:00
|
|
|
|
}
|