From 25143645d348c3a5caac9a46bc681d5b57cb388f Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 21:24:34 +0300 Subject: [PATCH] =?UTF-8?q?lct-15:=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BE=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=BA=D0=B0=20=D0=BA=D1=83=D1=80=D1=81=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B4=D0=BE=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Правило сервера, а не интерфейса: score.ready курсанту уходит только после self_assessment.submit, обойти его через DevTools нельзя. Преподаватель и монитор получают оценку сразу — им ждать нечего. Чек-лист для самооценки отдаётся отдельной точкой и только после конца звонка: во время разговора это содержимое подсказок, и открыть его значило бы выдать в контрольном режиме то, чего там быть не должно. Попутно подключён расчёт оценки при завершении звонка — детерминированный слой был написан в lct-12, но его никто не вызывал. Тест на отсутствие события смотрит очередь курсанта, а не ждёт из сокета: ожидание того, чего не будет, вешает прогон навсегда. --- backend/app/api/http/sessions.py | 30 +++++++++ backend/app/api/ws/call.py | 6 ++ backend/app/session/finish.py | 53 +++++++++++++++ backend/app/session/hub.py | 1 + backend/app/session/journal.py | 9 ++- backend/app/session/state.py | 5 ++ backend/tests/test_ws.py | 64 ++++++++++++++++++ frontend/src/features/call/useCall.ts | 21 +++++- .../self-assessment/SelfAssessment.tsx | 65 +++++++++++++++++++ frontend/src/pages/trainee/Call.tsx | 8 +++ 10 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 backend/app/session/finish.py create mode 100644 frontend/src/features/self-assessment/SelfAssessment.tsx diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index ad5c9fd..975fef9 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -14,6 +14,8 @@ 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.scenarios import store +from app.session.hub import hub router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -73,6 +75,34 @@ async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> Ses return _out(session) +class ChecklistItemOut(BaseModel): + id: str + question: str + + +@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut]) +async def checklist(session_id: UUID) -> list[ChecklistItemOut]: + """Чек-лист для самооценки — **только после конца звонка**. + + Во время звонка это содержимое подсказок: отдать его значит выдать + в контрольном режиме то, чего там быть не должно. После звонка курсант + по нему отмечает, что, по его мнению, пропустил. + """ + state = hub.get(session_id) + if state is None: + raise HTTPException(status_code=404, detail="session_not_found") + if not state.ended: + raise HTTPException(status_code=409, detail="call_not_ended") + scenario = store.get(state.scenario_id) + if scenario is None: + raise HTTPException(status_code=404, detail="scenario_not_found") + return [ + ChecklistItemOut(id=item.id, question=item.question) + for item in scenario.checklist + if item.question + ] + + @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 2041019..6bb9285 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -27,6 +27,7 @@ from app.domain.events import ( ) from app.domain.events import BgStart from app.scenarios import store +from app.session.finish import finish, release_score from app.session.hub import hub from app.session.state import now_utc from app.voice.models import get_voice_models @@ -125,10 +126,14 @@ async def _handle(session_id: UUID, state, event) -> None: state.on_event("callback.dial") case "self_assessment.submit": + state.self_assessed = True + state.self_assessment = {"missed": event.missed, "comment": event.comment} if hub.journal: await hub.journal.self_assessment( session_id, event.missed, event.comment, now_utc() ) + # Оценка могла быть готова раньше самооценки — теперь её можно отдать. + await release_score(session_id, state) case "call.hangup": if state.voice is not None: @@ -142,6 +147,7 @@ async def _handle(session_id: UUID, state, event) -> None: await hub.journal.session_ended( session_id, state.ended_at, CallEndReason.HANGUP.value ) + await finish(session_id, state) def _start_voice(session_id: UUID, state) -> None: diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py new file mode 100644 index 0000000..a37eeb5 --- /dev/null +++ b/backend/app/session/finish.py @@ -0,0 +1,53 @@ +"""Завершение занятия: посчитать оценку и положить её в журнал. + +Детерминированный слой считается сразу по завершении звонка (lct-12). +Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет +своё ощущение с объективной картиной, и расхождение — отдельный материал +для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают +событие сразу: им ждать нечего. +""" + +import logging +from uuid import UUID + +from app.domain.events import ScoreReady +from app.scenarios import store +from app.scoring.competency import radar +from app.scoring.gost import evaluate +from app.session.hub import hub + +log = logging.getLogger(__name__) + + +async def finish(session_id: UUID, state) -> None: + scenario = store.get(state.scenario_id) + if scenario is None: + return + + result = evaluate( + scenario=scenario, + kio=state.kio, + timers=state.timers, + revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, + end_reason=state.end_reason, + ) + state.score = { + "score_auto": result.score, + "metrics": [metric.model_dump() for metric in result.metrics], + "findings": [finding.model_dump(mode="json") for finding in result.findings], + "competencies": [item.model_dump() for item in radar(result.metrics)], + "unavailable": result.unavailable, + } + log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings)) + + if hub.journal: + await hub.journal.score(session_id, result.score, state.score) + + hub.to_observers(session_id, ScoreReady(session_id=session_id)) + await release_score(session_id, state) + + +async def release_score(session_id: UUID, state) -> None: + """Отдать оценку курсанту, когда самооценка сдана.""" + if state.score is not None and state.self_assessed: + hub.to_trainee(session_id, ScoreReady(session_id=session_id)) diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py index e80ad21..7fa83c6 100644 --- a/backend/app/session/hub.py +++ b/backend/app/session/hub.py @@ -34,6 +34,7 @@ class Journal(Protocol): async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ... async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ... async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ... + async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ... async def session_started(self, session_id: UUID, at) -> None: ... async def session_ended(self, session_id: UUID, at, reason: str) -> None: ... diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py index e52432c..003f3ae 100644 --- a/backend/app/session/journal.py +++ b/backend/app/session/journal.py @@ -12,7 +12,7 @@ from sqlalchemy import update from sqlalchemy.ext.asyncio import async_sessionmaker from app.db import repo -from app.db.models import SelfAssessment, Session +from app.db.models import Score, SelfAssessment, Session log = logging.getLogger(__name__) @@ -84,6 +84,13 @@ class DbJournal: await self._write(lambda db: action(db)) + async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: + async def action(db): + db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report)) + await db.commit() + + await self._write(lambda db: action(db)) + async def session_started(self, session_id: UUID, at: datetime) -> None: async def action(db): await db.execute(update(Session).where(Session.id == session_id).values(started_at=at)) diff --git a/backend/app/session/state.py b/backend/app/session/state.py index e52bd2f..8db6489 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -61,6 +61,11 @@ class SessionState: audio_frames: int = 0 bad_frames: int = 0 + # Самооценка сдана — до неё курсант не видит автооценку. + self_assessed: bool = False + self_assessment: dict | None = None + score: dict | None = None + started_at: datetime | None = None ended_at: datetime | None = None end_reason: CallEndReason | None = None diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 88b98a2..325977c 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -194,3 +194,67 @@ def test_events_still_work_between_audio_frames(client): trainee.send_bytes(b"\x00\x00" * 320) wait_for(lambda: state.kio.floor == "5") assert state.audio_frames == 2 + + +def test_score_waits_for_self_assessment(client): + """Курсант сначала сверяет своё ощущение с объективной картиной: + расхождение самооценки с автооценкой — отдельный материал для преподавателя.""" + with lesson(client) as (session_id, _): + state = hub.get(session_id) + with client.websocket_connect(f"/ws/observe/{session_id}") as observer: + observer.receive_json() + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + trainee.send_json({"type": "call.answer"}) + trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14"}}) + trainee.send_json({"type": "call.hangup"}) + + # Преподавателю и монитору — сразу: им ждать нечего. + assert read_until(observer, "score.ready")["session_id"] == str(session_id) + wait_for(lambda: state.score is not None) + + trainee.send_json({"type": "self_assessment.submit", + "missed": ["q_people"], "comment": "растерялся на адресе"}) + message = read_until(trainee, "score.ready") + + assert message["session_id"] == str(session_id) + assert state.self_assessment == {"missed": ["q_people"], "comment": "растерялся на адресе"} + assert state.score["score_auto"] >= 0 + + +def test_trainee_gets_no_score_without_self_assessment(client): + """Отсутствие события проверяется подпиской на очередь курсанта: + ждать его из сокета нечем — чтение заблокируется навсегда.""" + from app.domain.events import ScoreReady + + with lesson(client) as (session_id, _): + state = hub.get(session_id) + with hub.trainee(session_id) as queue: + 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: state.score is not None) + time.sleep(0.3) + + delivered = [] + while not queue.empty(): + delivered.append(queue.get_nowait()) + + assert not any(isinstance(event, ScoreReady) for event in delivered), ( + "оценка ушла курсанту до самооценки" + ) + + +def test_checklist_is_closed_until_the_call_is_over(client): + """Во время звонка чек-лист — это содержимое подсказок.""" + with lesson(client) as (session_id, _): + during = client.get(f"/api/sessions/{session_id}/checklist") + assert during.status_code == 409, during.text + + 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).ended) + + after = client.get(f"/api/sessions/{session_id}/checklist") + assert after.status_code == 200 + assert all(item["question"] for item in after.json()) diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index 826c504..ce38d81 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -11,6 +11,7 @@ import { type Capture, startCapture } from "@/shared/audio/capture"; 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"; /** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */ @@ -33,6 +34,9 @@ export function useCall(sessionId: string | null) { const [callerSpeaking, setCallerSpeaking] = useState(false); const [micOn, setMicOn] = useState(false); const [card, setCard] = useState(emptyCard); + const [checklist, setChecklist] = useState([]); + const [selfAssessed, setSelfAssessed] = useState(false); + const [scoreReady, setScoreReady] = useState(false); const [error, setError] = useState(null); const channel = useRef | null>(null); @@ -98,6 +102,15 @@ export function useCall(sessionId: string | null) { case "call.ended": setPhase("ended"); audio.current?.ambience.stop(); + // Чек-лист открывается только после звонка: во время него это + // содержимое подсказок. + void fetch(`/api/sessions/${sessionId}/checklist`) + .then((response) => (response.ok ? response.json() : [])) + .then(setChecklist) + .catch(() => setChecklist([])); + break; + case "score.ready": + setScoreReady(true); break; case "error": setError(event.message); @@ -162,8 +175,14 @@ export function useCall(sessionId: string | null) { channel.current?.send({ type: "dds.dispatch", service }); }, []); + const submitSelfAssessment = useCallback((missed: string[], comment: string) => { + channel.current?.send({ type: "self_assessment.submit", missed, comment }); + setSelfAssessed(true); + }, []); + return { status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card, - answer, hangup, hint, patchKio, dispatch, + checklist, selfAssessed, scoreReady, + answer, hangup, hint, patchKio, dispatch, submitSelfAssessment, }; } diff --git a/frontend/src/features/self-assessment/SelfAssessment.tsx b/frontend/src/features/self-assessment/SelfAssessment.tsx new file mode 100644 index 0000000..b7734a6 --- /dev/null +++ b/frontend/src/features/self-assessment/SelfAssessment.tsx @@ -0,0 +1,65 @@ +// Самооценка курсанта — до показа автооценки. +// +// Педагогический смысл — рефлексия: курсант сверяет своё ощущение с объективной +// оценкой, а расхождение — отдельный материал для преподавателя. Курсант, +// не заметивший, что пропустил вопрос о пострадавших, — более важный случай, +// чем сама ошибка (docs/product/DEBRIEF.md). + +import { useState } from "react"; + +export interface ChecklistItem { + id: string; + question: string; +} + +export function SelfAssessment({ + checklist, + onSubmit, +}: { + checklist: ChecklistItem[]; + onSubmit: (missed: string[], comment: string) => void; +}) { + const [missed, setMissed] = useState([]); + const [comment, setComment] = useState(""); + + const toggle = (id: string) => + setMissed((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + + return ( +
+

Самооценка

+

Отметьте вопросы, которые, по вашему мнению, вы пропустили. Оценка откроется после.

+ + + {checklist.map((item) => ( + + + + + ))} + + + + + +
+ + {item.question}
Комментарий + setComment(event.target.value)} + /> +
+

+ +

+
+ ); +} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index 93b33b1..82f91c4 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 { SelfAssessment } from "@/features/self-assessment/SelfAssessment"; import { InterviewTimer, Timers } from "@/features/timers/Timers"; import { display } from "@/features/kio-card/merge"; import { useCall } from "@/features/call/useCall"; @@ -54,6 +55,13 @@ export function Call() { {call.error &&

{call.error}

} + {call.phase === "ended" && !call.selfAssessed && call.checklist.length > 0 && ( + + )} + {call.selfAssessed && ( +

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

+ )} +

Карточка информационного обмена