diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index 975fef9..81d97e0 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -13,8 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import repo from app.db.base import get_session -from app.domain.events import SessionMode +from app.domain.events import SessionMode, SessionReport from app.scenarios import store +from app.scoring.report import build as build_report from app.session.hub import hub router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -103,6 +104,53 @@ async def checklist(session_id: UUID) -> list[ChecklistItemOut]: ] +class ScoreOverride(BaseModel): + """Коррекция оценки преподавателем. Автооценка сохраняется рядом.""" + + score_final: float + comment: str = "" + author: str = "преподаватель" + + +def _live(session_id: UUID): + state = hub.get(session_id) + if state is None: + raise HTTPException(status_code=404, detail="session_not_found") + scenario = store.get(state.scenario_id) + if scenario is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + return state, scenario + + +@router.get("/{session_id}/report", response_model=SessionReport) +async def report(session_id: UUID) -> SessionReport: + """Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки. + + Оценка курсанту открывается событием `score.ready` после самооценки; здесь + прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер + занятия (docs/arch/CONTRACT.md). + """ + state, scenario = _live(session_id) + if state.score is None: + raise HTTPException(status_code=409, detail="score_not_ready") + return build_report(session_id, state, scenario) + + +@router.patch("/{session_id}/report", response_model=SessionReport) +async def override(session_id: UUID, body: ScoreOverride) -> SessionReport: + """Тренажёр готовит материал, преподаватель имеет последнее слово.""" + state, scenario = _live(session_id) + if state.score is None: + raise HTTPException(status_code=409, detail="score_not_ready") + state.score = { + **state.score, + "score_final": body.score_final, + "overridden_by": body.author, + "override_comment": body.comment, + } + return build_report(session_id, state, scenario) + + @router.get("", response_model=list[SessionOut]) async def listing( trainee: UUID | None = None, diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 6bb9285..32395a6 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -111,6 +111,7 @@ async def _handle(session_id: UUID, state, event) -> None: return checklist_id, question = nxt state.hints_shown.append(checklist_id) + state.hints_log.append((checklist_id, now_utc())) shown = HintShown(checklist_id=checklist_id, question=question) hub.broadcast(session_id, shown) if hub.journal: diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 752352c..7e0792f 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -16,6 +16,7 @@ from pydantic import TypeAdapter, ValidationError from app.domain.events import ( CallEndReason, + ScoreReady, CallIncoming, ErrorEvent, ErrorKind, @@ -131,6 +132,14 @@ async def control(ws: WebSocket, session_id: UUID) -> None: case "session.stop": await _stop(session_id) case "instructor_note.add": + state = hub.get(session_id) + if state is not None: + state.notes.append({ + "type": "instructor_note.shown", + "transcript_ref": event.transcript_ref, + "text": event.text, + "author": "преподаватель", + }) hub.to_observers( session_id, InstructorNoteShown( @@ -147,6 +156,17 @@ async def control(ws: WebSocket, session_id: UUID) -> None: state = hub.get(session_id) if state is not None: hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id)) + case "score.override": + state = hub.get(session_id) + if state is not None and state.score is not None: + # Автооценка остаётся рядом: видно, что скорректировано и кем. + state.score = { + **state.score, + "score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"], + "overridden_by": "преподаватель", + "override_comment": event.comment, + } + hub.to_observers(session_id, ScoreReady(session_id=session_id)) case "director.inject": # Поведение звонящего — карточка lct-07, пульт — lct-22. # До них директива копится в состоянии и видна в разборе. diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index 381e84e..43da60f 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -511,6 +511,21 @@ class SelfAssessment(BaseModel): submitted_at: datetime +class SelfAssessmentDiff(BaseModel): + """Расхождение самооценки с автооценкой — отдельный материал для преподавателя. + + Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный + случай, чем сама ошибка (docs/product/DEBRIEF.md). + """ + + #: Пропустил и сам это заметил. + noticed: list[str] = [] + #: Пропустил и не заметил — самое ценное для разбора. + unnoticed: list[str] = [] + #: Отметил как пропущенное, хотя на деле спросил. + overcautious: list[str] = [] + + class SessionReport(BaseModel): """Единица истории: из отчётов складываются профиль, дельта попыток, аналитика.""" @@ -526,6 +541,8 @@ class SessionReport(BaseModel): missed_checklist: list[str] hints_used: list[HintUsage] self_assessment: SelfAssessment | None = None + self_assessment_diff: SelfAssessmentDiff | None = None + notes: list[InstructorNoteShown] = [] score_auto: float score_final: float overridden_by: str | None = None diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py index fcf7965..669811d 100644 --- a/backend/app/scoring/gost.py +++ b/backend/app/scoring/gost.py @@ -14,7 +14,7 @@ from app.domain.kio import KIO, missing_fields from app.domain.taxonomy import ERRORS, Competency, Finding, FindingSource from app.domain.timers import GOST_REF, NORMATIVES, TimerCode from app.scenarios.schema import Scenario -from app.scoring.taxonomy import METRIC_MAP +from app.scoring.taxonomy import METRIC_MAP, METRIC_WEIGHTS from app.session.timers import SessionTimers SOURCE_BY_CODE = { @@ -64,7 +64,8 @@ class _Builder: def add(self, key: str, title: str, fact: str, norm: str, passed: bool, ref: str | None = None, finding: str | None = None) -> None: self.result.metrics.append( - Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed) + Metric(key=key, title=title, fact=fact, norm=norm, ref=ref, passed=passed, + weight=METRIC_WEIGHTS.get(key, 1.0)) ) if passed: return @@ -156,6 +157,7 @@ def evaluate( norm="все обязательные факты", ref="чек-лист сценария", passed=len(got) == len(required), + weight=METRIC_WEIGHTS["checklist_completeness"], ) ) # По отметке на каждый недобытый факт: в разборе нужен конкретный diff --git a/backend/app/scoring/reference.py b/backend/app/scoring/reference.py new file mode 100644 index 0000000..7d7e79a --- /dev/null +++ b/backend/app/scoring/reference.py @@ -0,0 +1,70 @@ +"""Эталонный диалог: как должен был пройти опрос. + +Собирается **кодом** из чек-листа и фактов сценария. Писать его руками нельзя: +методист поправит факт и забудет эталон, и курсант получит штраф за правильный +ответ (docs/spec/SCENARIO-FORMAT.md). + +Работает в двух местах: в разборе — «вот какой вопрос стоял на этом шаге» +(docs/product/DEBRIEF.md), и в эталон-плеере на внешнем мониторе, когда +появится предгенерация озвучки (lct-19, требует ключа LLM). +""" + +from pydantic import BaseModel + +from app.dialog.caller import REVEAL +from app.dialog.persona import BASE_MOOD +from app.domain.events import Mood +from app.scenarios.schema import Scenario + + +class ReferenceStep(BaseModel): + """Шаг эталонного опроса: вопрос оператора и ответ звонящего на него.""" + + checklist_id: str + question: str + fact_id: str | None = None + answer: str | None = None + required: bool = False + hidden: bool = False + + +class ReferenceDialog(BaseModel): + scenario_id: str + first_line: str + steps: list[ReferenceStep] + + +def build(scenario: Scenario) -> ReferenceDialog: + facts = {fact.id: fact for fact in scenario.facts} + required = set(scenario.ground_truth.required_facts) + mood = BASE_MOOD.get(scenario.persona.base, Mood.CALM) + + steps: list[ReferenceStep] = [] + for item in scenario.checklist: + if not item.question: + continue + fact = facts.get(item.fact) if item.fact else None + steps.append( + ReferenceStep( + checklist_id=item.id, + question=item.question, + fact_id=fact.id if fact else None, + # Ответ звонящего — тем же шаблоном, каким он отвечает в живом + # звонке: эталон должен звучать так же, а не литературно. + answer=REVEAL[mood].format(fact=fact.value) if fact else None, + required=bool(fact and fact.id in required), + hidden=bool(fact and fact.hidden), + ) + ) + return ReferenceDialog(scenario_id=scenario.id, first_line=scenario.first_line, steps=steps) + + +def missed_steps(scenario: Scenario, revealed: list[str] | None) -> list[ReferenceStep]: + """Шаги, которые курсант не отработал. `None` — слот-автомата не было.""" + if revealed is None: + return [] + return [ + step + for step in build(scenario).steps + if step.fact_id and step.fact_id not in revealed and step.required + ] diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py new file mode 100644 index 0000000..0baaac0 --- /dev/null +++ b/backend/app/scoring/report.py @@ -0,0 +1,93 @@ +"""Сборка отчёта сессии — единицы истории. + +Из отчётов складываются профиль курсанта, дельта попыток и аналитика группы. +Ни одной отметки без обоснования: у каждой есть код, факт и норматив +(docs/product/DEBRIEF.md). +""" + +from uuid import UUID + +from app.domain.events import ( + CompetencyScore, + HintShown, + HintUsage, + InstructorNoteShown, + Metric, + SelfAssessment, + SelfAssessmentDiff, + SessionReport, +) +from app.domain.taxonomy import Finding +from app.scenarios.schema import Scenario +from app.scoring.reference import build as build_reference + + +def assess_difference( + scenario: Scenario, revealed: list[str] | None, missed_claimed: list[str] +) -> SelfAssessmentDiff: + """Сверить, что курсант считает пропущенным, с тем, что он пропустил на деле.""" + reference = build_reference(scenario) + required = {step.checklist_id: step.fact_id for step in reference.steps if step.required} + really_missed = { + checklist_id + for checklist_id, fact_id in required.items() + if revealed is not None and fact_id not in revealed + } + claimed = set(missed_claimed) + return SelfAssessmentDiff( + noticed=sorted(claimed & really_missed), + unnoticed=sorted(really_missed - claimed), + overcautious=sorted(claimed - really_missed), + ) + + +def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: + score = state.score or {} + revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else None + reference = build_reference(scenario) + + self_assessment = None + difference = None + if state.self_assessment is not None: + self_assessment = SelfAssessment( + missed=state.self_assessment["missed"], + comment=state.self_assessment.get("comment", ""), + submitted_at=state.ended_at or state.transcript[-1].at, + ) + difference = assess_difference(scenario, revealed, self_assessment.missed) + + questions = {step.checklist_id: step.question for step in reference.steps} + missed_checklist = [ + step.checklist_id + for step in reference.steps + if step.required and revealed is not None and step.fact_id not in revealed + ] + + return SessionReport( + session_id=session_id, + scenario_id=scenario.id, + mode=state.mode, + attempt=state.attempt, + transcript=list(state.transcript), + findings=[Finding.model_validate(item) for item in score.get("findings", [])], + metrics=[Metric.model_validate(item) for item in score.get("metrics", [])], + competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])], + # Эталонные вопросы по шагам: по каждому пропущенному пункту видно, + # какой вопрос был правильным. + reference_questions=[ + HintShown(checklist_id=step.checklist_id, question=step.question) + for step in reference.steps + ], + missed_checklist=missed_checklist, + hints_used=[ + HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at) + for checklist_id, at in state.hints_log + ], + self_assessment=self_assessment, + self_assessment_diff=difference, + notes=[InstructorNoteShown.model_validate(note) for note in state.notes], + score_auto=score.get("score_auto", 0.0), + score_final=score.get("score_final", score.get("score_auto", 0.0)), + overridden_by=score.get("overridden_by"), + override_comment=score.get("override_comment"), + ) diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 5c11ca4..2eb8e47 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -19,6 +19,25 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "required_fields": (ErrorCode.E5, Competency.CARD), } +#: Вес метрики в детерминированной оценке. +#: +#: **Предварительные значения, требуют утверждения методистом.** Без весов все +#: метрики равны, и курсант, не задавший ни одного вопроса, но заполнивший +#: карточку руками, получает 87 из 100: «ответ за секунду» стоит столько же, +#: сколько «добыл все обязательные факты». Опрос — то, ради чего существует +#: тренажёр, поэтому он весит больше всего. +METRIC_WEIGHTS: dict[str, float] = { + "checklist_completeness": 4.0, + "incident_type": 2.0, + "dds_choice": 2.0, + "address": 2.0, + "required_fields": 2.0, + "interview_time": 1.5, + "victims_count": 1.0, + "answer_time": 1.0, + "callback": 1.0, +} + #: Вес детерминированного слоя в итоговой оценке. Остальное — LLM-судья #: на мягкие критерии (E4), и не больше (docs/arch/BACKEND.md). DETERMINISTIC_WEIGHT = 0.6 diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 8db6489..a333e7d 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -45,6 +45,9 @@ class SessionState: transcript: list[TranscriptEntry] = field(default_factory=list) timers: SessionTimers = field(default_factory=SessionTimers) hints_shown: list[str] = field(default_factory=list) + #: Когда именно подсказывали — в разборе видно, какой пункт и на какой минуте. + hints_log: list[tuple[str, datetime]] = field(default_factory=list) + notes: list[dict] = field(default_factory=list) directives: list[str] = field(default_factory=list) # Звонящий. Автомата нет, если не скачана модель эмбеддингов: diff --git a/backend/tests/test_reference.py b/backend/tests/test_reference.py new file mode 100644 index 0000000..af10d86 --- /dev/null +++ b/backend/tests/test_reference.py @@ -0,0 +1,48 @@ +"""Эталонный диалог собирается из фактов, а не пишется руками.""" + +from pathlib import Path + +import pytest + +from app.scenarios.loader import load_file +from app.scoring.reference import build, missed_steps + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + + +@pytest.fixture(scope="module") +def scenario(): + return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + + +def test_every_checklist_question_becomes_a_step(scenario): + reference = build(scenario) + assert [step.checklist_id for step in reference.steps] == [ + item.id for item in scenario.checklist if item.question + ] + assert reference.first_line == scenario.first_line + + +def test_answers_come_from_facts_not_from_prose(scenario): + """Эталон не может разойтись с фактами: ответы взяты из них же.""" + reference = build(scenario) + address = next(step for step in reference.steps if step.fact_id == "f_address") + fact = next(fact for fact in scenario.facts if fact.id == "f_address") + assert fact.value in address.answer + + +def test_step_without_a_fact_has_no_answer(scenario): + """«Представьтесь» факта не добывает — и ответа в эталоне у него нет.""" + caller = next(step for step in build(scenario).steps if step.checklist_id == "q_caller") + assert caller.fact_id is None and caller.answer is None + assert not caller.required + + +def test_missed_steps_are_only_the_required_ones(scenario): + missed = missed_steps(scenario, revealed=["f_address"]) + assert [step.fact_id for step in missed] == ["f_what_burns", "f_people", "f_smoke", "f_gas"] + assert all(step.question for step in missed), "в разборе нужен текст эталонного вопроса" + + +def test_without_slot_machine_nothing_is_declared_missed(scenario): + assert missed_steps(scenario, revealed=None) == [] diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 325977c..669b7a8 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -258,3 +258,52 @@ def test_checklist_is_closed_until_the_call_is_over(client): after = client.get(f"/api/sessions/{session_id}/checklist") assert after.status_code == 200 assert all(item["question"] for item in after.json()) + + +def test_report_shows_missed_questions_and_self_assessment_gap(client): + """Главное в разборе: по пропущенному пункту виден эталонный вопрос, + а расхождение самооценки — отдельным блоком.""" + with lesson(client) as (session_id, _): + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + trainee.send_json({"type": "call.answer"}) + trainee.send_json({"type": "hint.request"}) + trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}}) + trainee.send_json({"type": "call.hangup"}) + wait_for(lambda: hub.get(session_id).score is not None) + # Курсант считает, что пропустил только адрес — а не добыл он всё. + trainee.send_json({"type": "self_assessment.submit", "missed": ["q_address"], "comment": "торопился"}) + wait_for(lambda: hub.get(session_id).self_assessed) + + response = client.get(f"/api/sessions/{session_id}/report") + assert response.status_code == 200, response.text + report = response.json() + + assert report["missed_checklist"], "не добытые пункты должны быть перечислены" + questions = {item["checklist_id"]: item["question"] for item in report["reference_questions"]} + assert all(questions.get(item) for item in report["missed_checklist"]), "у пропущенного нет эталонного вопроса" + + for finding in report["findings"]: + assert finding["code"] and finding["fact"], "отметка без обоснования" + + diff = report["self_assessment_diff"] + assert "q_address" in diff["overcautious"] or "q_address" in diff["noticed"] + assert diff["unnoticed"], "курсант не заметил часть пропущенного — это и есть материал разбора" + assert report["hints_used"], "использованные подсказки попадают в разбор" + + +def test_instructor_correction_keeps_the_automatic_score(client): + with lesson(client) as (session_id, _): + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + trainee.send_json({"type": "call.answer"}) + trainee.send_json({"type": "call.hangup"}) + wait_for(lambda: hub.get(session_id).score is not None) + + auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"] + corrected = client.patch( + f"/api/sessions/{session_id}/report", + json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"}, + ).json() + + assert corrected["score_final"] == 80.0 + assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом" + assert corrected["overridden_by"] == "преподаватель" diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 8c23ce9..261fd3e 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,6 +1,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { Call } from "@/pages/trainee/Call"; +import { Wall } from "@/pages/wall/Wall"; import { Stub } from "@/shared/ui/Stub"; // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). @@ -9,6 +10,6 @@ export const router = createBrowserRouter([ { path: "/", element: }, { path: "/trainee", element: }, { path: "/instructor", element: }, - { path: "/wall", element: }, + { path: "/wall", element: }, { path: "/dds", element: }, ]); diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index ce38d81..d58aca9 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -12,7 +12,13 @@ import { EnergyGate } from "@/shared/audio/levels"; import { CALLER_RATE, Playback } from "@/shared/audio/playback"; import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge"; import type { ChecklistItem } from "@/features/self-assessment/SelfAssessment"; -import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated"; +import type { + CallIncoming, + DDSCode, + ServerToTrainee, + SessionReport, + TimerSnapshot, +} from "@/shared/types/generated"; /** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */ const PATCH_DEBOUNCE_MS = 300; @@ -37,6 +43,7 @@ export function useCall(sessionId: string | null) { const [checklist, setChecklist] = useState([]); const [selfAssessed, setSelfAssessed] = useState(false); const [scoreReady, setScoreReady] = useState(false); + const [report, setReport] = useState(null); const [error, setError] = useState(null); const channel = useRef | null>(null); @@ -111,6 +118,10 @@ export function useCall(sessionId: string | null) { break; case "score.ready": setScoreReady(true); + void fetch(`/api/sessions/${sessionId}/report`) + .then((response) => (response.ok ? response.json() : null)) + .then(setReport) + .catch(() => setReport(null)); break; case "error": setError(event.message); @@ -182,7 +193,7 @@ export function useCall(sessionId: string | null) { return { status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card, - checklist, selfAssessed, scoreReady, + checklist, selfAssessed, scoreReady, report, answer, hangup, hint, patchKio, dispatch, submitSelfAssessment, }; } diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx new file mode 100644 index 0000000..875f15c --- /dev/null +++ b/frontend/src/features/debrief/Debrief.tsx @@ -0,0 +1,148 @@ +// Разбор после звонка — главный учебный момент занятия. +// +// Ни одной отметки без обоснования: у каждой есть код, факт и норматив. +// По каждому пропущенному пункту видно, каким был эталонный вопрос +// (docs/product/DEBRIEF.md). + +import type { SessionReport } from "@/shared/types/generated"; + +import { Radar } from "./Radar"; + +export function Debrief({ report, big = false }: { report: SessionReport; big?: boolean }) { + const questions = new Map(report.reference_questions.map((item) => [item.checklist_id, item.question])); + const diff = report.self_assessment_diff; + // Поля со значениями по умолчанию приходят из контракта необязательными. + const unnoticed = diff?.unnoticed ?? []; + const noticed = diff?.noticed ?? []; + const overcautious = diff?.overcautious ?? []; + const notes = report.notes ?? []; + + return ( +
+

+ Разбор · оценка {report.score_final.toFixed(0)} из 100 + {report.overridden_by && ` · скорректировано (${report.overridden_by}), автооценка ${report.score_auto.toFixed(0)}`} +

+ +
+
+

Нормативы и факты

+ + + {report.metrics.map((metric) => ( + + + + + ))} + +
{metric.title} + {metric.fact} · норматив {metric.norm} + {metric.ref && ({metric.ref})} +
+ +

Отметки

+ + + {report.findings.map((finding, index) => ( + + + + + ))} + {report.findings.length === 0 && } + +
{finding.code} + {finding.summary} +
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+
ошибок не найдено
+
+ +
+

Компетенции

+ + + {report.missed_checklist.length > 0 && ( + <> +

Пропущенные вопросы

+ + + {report.missed_checklist.map((id) => ( + + + + + ))} + +
{id}эталонный вопрос: «{questions.get(id)}»
+ + )} + + {diff && ( + <> +

Самооценка

+ + + + + + + + + + + + + + + {report.self_assessment?.comment && ( + + )} + +
Не заметил, что пропустил + {unnoticed.map((id) => questions.get(id) ?? id).join("; ") || "—"} +
Заметил сам{noticed.map((id) => questions.get(id) ?? id).join("; ") || "—"}
Отметил зря — спрашивал{overcautious.map((id) => questions.get(id) ?? id).join("; ") || "—"}
Комментарий курсанта{report.self_assessment.comment}
+ + )} + + {report.hints_used.length > 0 && ( + <> +

Использованные подсказки

+ + + {report.hints_used.map((hint, index) => ( + + ))} + +
{hint.checklist_id}{hint.question}
+ + )} +
+
+ +

Транскрипт

+ + + {report.transcript.map((line) => { + const marks = report.findings.filter((finding) => finding.transcript_ref === line.ref); + const note = notes.find((item) => item.transcript_ref === line.ref); + return ( + + + + + ); + })} + +
{line.speaker === "caller" ? "звонящий" : "оператор"} + {line.text} + {marks.map((mark, index) => ( +
+ {mark.code}: {mark.summary} +
+ ))} + {note &&
пометка преподавателя: {note.text}
} +
+
+ ); +} diff --git a/frontend/src/features/debrief/Radar.tsx b/frontend/src/features/debrief/Radar.tsx new file mode 100644 index 0000000..a4dd314 --- /dev/null +++ b/frontend/src/features/debrief/Radar.tsx @@ -0,0 +1,48 @@ +// Радар компетенций: проекция уже посчитанных метрик, без пересчёта весов. +// Преподаватель читает его за секунду: где провал — там тема следующего занятия +// (docs/product/METHODOLOGY.md). + +import type { CompetencyScore } from "@/shared/types/generated"; + +export const COMPETENCY_LABELS: Record = { + intake: "Приём вызова", + interview: "Опрос", + card: "Карточка", + routing: "Маршрутизация", + norms: "Нормативы", + communication: "Коммуникация", +}; + +export function Radar({ values, size = 220 }: { values: CompetencyScore[]; size?: number }) { + if (values.length < 3) return null; + const center = size / 2; + const radius = center - 28; + + const point = (index: number, value: number) => { + const angle = (Math.PI * 2 * index) / values.length - Math.PI / 2; + return [center + radius * value * Math.cos(angle), center + radius * value * Math.sin(angle)]; + }; + const polygon = values.map((item, index) => point(index, item.value).join(",")).join(" "); + + return ( + + {[0.25, 0.5, 0.75, 1].map((ring) => ( + point(index, ring).join(",")).join(" ")} + fill="none" + stroke="var(--line)" + /> + ))} + + {values.map((item, index) => { + const [x, y] = point(index, 1.16); + return ( + + {COMPETENCY_LABELS[item.competency] ?? item.competency} + + ); + })} + + ); +} diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts new file mode 100644 index 0000000..0ae78ab --- /dev/null +++ b/frontend/src/features/observe/useObserve.ts @@ -0,0 +1,98 @@ +// Наблюдатель: внешний монитор и пульт преподавателя. +// +// Снимок при подключении обязателен — монитор в классе включают посреди занятия, +// и он должен показать текущее состояние, а не ждать событий. Отправлять в этот +// канал нечего и нельзя: тип `Out` — `never` (docs/arch/CONTRACT.md). + +import { useEffect, useState } from "react"; + +import { type CardState, applyState, empty as emptyCard } from "@/features/kio-card/merge"; +import { observeChannel, type ChannelStatus } from "@/shared/api/ws"; +import type { + SessionMode, + SessionReport, + TimerSnapshot, + TranscriptEntry, +} from "@/shared/types/generated"; + +export interface ObservedSession { + status: ChannelStatus; + title: string | null; + mode: SessionMode | undefined; + trainee: string | null; + card: CardState; + requiredFields: string[]; + transcript: TranscriptEntry[]; + timers: TimerSnapshot[]; + hintsUsed: number; + notes: Map; + ended: boolean; + report: SessionReport | null; +} + +export function useObserve(sessionId: string | null): ObservedSession { + const [status, setStatus] = useState("connecting"); + const [title, setTitle] = useState(null); + const [mode, setMode] = useState(undefined); + const [trainee, setTrainee] = useState(null); + const [card, setCard] = useState(emptyCard); + const [requiredFields, setRequiredFields] = useState([]); + const [transcript, setTranscript] = useState([]); + const [timers, setTimers] = useState([]); + const [hintsUsed, setHintsUsed] = useState(0); + const [notes, setNotes] = useState>(new Map()); + const [ended, setEnded] = useState(false); + const [report, setReport] = useState(null); + + useEffect(() => { + if (!sessionId) return; + const channel = observeChannel(sessionId, { + onStatus: setStatus, + onEvent: (event) => { + switch (event.type) { + case "session.snapshot": + setTitle(event.scenario_title); + setMode(event.mode); + setTrainee(event.trainee_name ?? null); + setCard((prev) => applyState(prev, event.kio as unknown as Record)); + setRequiredFields(event.required_fields ?? []); + setTranscript(event.transcript); + setTimers(event.timers); + setHintsUsed(event.hints_used ?? 0); + setEnded(event.ended ?? false); + break; + case "kio.state": + setCard((prev) => applyState(prev, event.kio as unknown as Record)); + break; + case "transcript.append": + setTranscript((prev) => [...prev, event.entry]); + break; + case "timer.tick": + setTimers(event.timers); + break; + case "mode.set": + setMode(event.mode); + break; + case "hint.shown": + setHintsUsed((count) => count + 1); + break; + case "instructor_note.shown": + setNotes((prev) => new Map(prev).set(event.transcript_ref, event.text)); + break; + case "session.ended": + setEnded(true); + break; + case "score.ready": + void fetch(`/api/sessions/${sessionId}/report`) + .then((response) => (response.ok ? response.json() : null)) + .then(setReport) + .catch(() => setReport(null)); + break; + } + }, + }).connect(); + return () => channel.close(); + }, [sessionId]); + + return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report }; +} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index 82f91c4..a8f2739 100644 --- a/frontend/src/pages/trainee/Call.tsx +++ b/frontend/src/pages/trainee/Call.tsx @@ -3,6 +3,7 @@ import { KioCard } from "@/features/kio-card/KioCard"; import { ModeBanner, hintsAllowed } from "@/features/mode-banner/ModeBanner"; +import { Debrief } from "@/features/debrief/Debrief"; import { SelfAssessment } from "@/features/self-assessment/SelfAssessment"; import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { display } from "@/features/kio-card/merge"; @@ -58,9 +59,8 @@ export function Call() { {call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && ( )} - {call.selfAssessed && ( -

{call.scoreReady ? "Оценка готова — разбор открывает преподаватель." : "Самооценка принята, оценка считается…"}

- )} + {call.selfAssessed && !call.report &&

Самооценка принята, оценка считается…

} + {call.report && }
diff --git a/frontend/src/pages/wall/Wall.tsx b/frontend/src/pages/wall/Wall.tsx new file mode 100644 index 0000000..d3f916e --- /dev/null +++ b/frontend/src/pages/wall/Wall.tsx @@ -0,0 +1,74 @@ +// Внешний монитор: группа смотрит, как идёт занятие, и разбирает вместе с ним. +// +// Это не отдельное приложение, а другой режим отрисовки тех же событий: крупный +// шрифт, высокий контраст, минимум деталей. Никакого взаимодействия — экран висит +// и показывает (docs/arch/FRONTEND.md). + +import { Debrief } from "@/features/debrief/Debrief"; +import { KioCard } from "@/features/kio-card/KioCard"; +import { ModeBanner } from "@/features/mode-banner/ModeBanner"; +import { InterviewTimer } from "@/features/timers/Timers"; +import { useObserve } from "@/features/observe/useObserve"; +import { sessionIdFromUrl } from "@/shared/api/session"; + +export function Wall() { + const sessionId = sessionIdFromUrl(); + const session = useObserve(sessionId); + + if (!sessionId) { + return ( +
+

Внешний монитор

+

Откройте ссылку вида /wall?session=…

+
+ ); + } + + if (session.report) { + return ( +
+ +

{session.title} · разбор

+ +
+ ); + } + + return ( +
+ +

+ {session.title ?? "Ожидание занятия"} + {session.trainee && ` · ${session.trainee}`} +

+ + +
+
+

Ход разговора

+ + + {session.transcript.slice(-12).map((line) => ( + + + + + ))} + {session.transcript.length === 0 && } + +
{line.speaker === "caller" ? "звонящий" : "оператор"} + {line.text} + {session.notes.has(line.ref) && ( +
пометка: {session.notes.get(line.ref)}
+ )} +
—
+

подсказок использовано: {session.hintsUsed}

+
+
+

Карточка

+ +
+
+
+ ); +} diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts index bafb8fc..812d80a 100644 --- a/frontend/src/shared/types/generated.ts +++ b/frontend/src/shared/types/generated.ts @@ -338,6 +338,13 @@ export interface SelfAssessment { submitted_at: string; } +/** Расхождение самооценки с автооценкой — отдельный материал для преподавателя. Курсант, не заметивший, что пропустил вопрос о пострадавших, — более важный случай, чем сама ошибка (docs/product/DEBRIEF.md). */ +export interface SelfAssessmentDiff { + noticed?: Array; + unnoticed?: Array; + overcautious?: Array; +} + /** Самооценка до показа автооценки. */ export interface SelfAssessmentSubmit { type: "self_assessment.submit"; @@ -367,6 +374,8 @@ export interface SessionReport { missed_checklist: Array; hints_used: Array; self_assessment?: SelfAssessment | null; + self_assessment_diff?: SelfAssessmentDiff | null; + notes?: Array; score_auto: number; score_final: number; overridden_by?: string | null; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7beb82d..e79c876 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -69,3 +69,16 @@ button:disabled { opacity: 0.5; cursor: default; } .mode-training { background: #e8f0fb; } .mode-exam { background: #fde8e8; } .mode-self { background: #eef3ea; } + +/* Разбор. Ссылка на норматив — мелким, но рядом с фактом: отметка без + обоснования не появляется ни в одном интерфейсе. */ +.debrief h3 { font-size: 15px; margin: 16px 0 6px; } +.ref { color: #5a6068; font-size: 12px; } +.radar { display: block; margin: 4px 0 8px; } + +/* Внешний монитор: читается с последнего ряда. */ +.wall { font-size: 20px; } +.wall h1 { font-size: 30px; } +.wall h2 { font-size: 24px; } +.wall .grid th { width: 160px; } +.debrief-big { font-size: 20px; }