From 38082e2af38670f802b9e3877b2a8bfc3af99489 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 21:42:12 +0300 Subject: [PATCH] =?UTF-8?q?lct-18:=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=BA=D1=83=D1=80=D1=81=D0=B0=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D1=80=D0=B0=D0=B4=D0=B0=D1=80,=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BF=D1=8B=D1=82?= =?UTF-8?q?=D0=BE=D0=BA,=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Работа над ошибками становится измеримым циклом: посмотрел разбор → попробовал снова → увидел дельту по оценке, времени опроса и добытым фактам. В отчёт занятия добавлена сводка числами — время опроса, факты, подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором текста метрик («94 с»), а это путь к тихим ошибкам. Радар — среднее по попыткам: одна неудачная попытка не определяет курсанта. Проверено сквозным тестом на живой базе: два занятия одного курсанта дают две попытки и посчитанную дельту. Проверка доступности базы в тесте была ложной — синхронного драйвера в проекте нет, и тест молча пропускался. --- backend/app/api/http/trainees.py | 138 +++++++++++++++++++ backend/app/main.py | 2 + backend/app/session/finish.py | 16 +++ backend/tests/test_profile.py | 88 ++++++++++++ frontend/src/app/router.tsx | 2 + frontend/src/pages/instructor/Instructor.tsx | 1 + frontend/src/pages/profile/Profile.tsx | 120 ++++++++++++++++ frontend/src/shared/api/http.ts | 45 ++++++ 8 files changed, 412 insertions(+) create mode 100644 backend/app/api/http/trainees.py create mode 100644 backend/tests/test_profile.py create mode 100644 frontend/src/pages/profile/Profile.tsx diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py new file mode 100644 index 0000000..3a7e9d8 --- /dev/null +++ b/backend/app/api/http/trainees.py @@ -0,0 +1,138 @@ +"""Профиль курсанта и прогресс. + +«Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент планирования +подготовки: радар компетенций, история попыток и дельта между ними +(docs/product/MODES.md#профиль-курсанта). Считается по журналу занятий. +""" + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.base import get_session +from app.db.models import Group, Score, Session, Trainee + +router = APIRouter(prefix="/api/trainees", tags=["trainees"]) + + +class TraineeOut(BaseModel): + id: UUID + name: str + group: str | None = None + + +class AttemptOut(BaseModel): + session_id: UUID + scenario_id: str + mode: str + attempt: int + created_at: datetime + score: float | None = None + interview_ms: int | None = None + facts_got: int | None = None + facts_required: int | None = None + hints: int | None = None + codes: dict[str, int] = {} + + +class DeltaOut(BaseModel): + """Что изменилось от попытки к попытке — измеримый цикл работы над ошибками.""" + + scenario_id: str + from_attempt: int + to_attempt: int + score: float | None = None + interview_ms: int | None = None + facts_got: int | None = None + + +class ProfileOut(BaseModel): + trainee: TraineeOut + attempts: list[AttemptOut] + competencies: dict[str, float] + deltas: list[DeltaOut] + hints_total: int + + +@router.get("", response_model=list[TraineeOut]) +async def listing(db: AsyncSession = Depends(get_session)) -> list[TraineeOut]: + rows = await db.execute( + select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True) + ) + return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows] + + +@router.get("/{trainee_id}/profile", response_model=ProfileOut) +async def profile(trainee_id: UUID, db: AsyncSession = Depends(get_session)) -> ProfileOut: + trainee = await db.get(Trainee, trainee_id) + if trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + group = await db.get(Group, trainee.group_id) if trainee.group_id else None + + rows = await db.execute( + select(Session, Score) + .join(Score, Score.session_id == Session.id, isouter=True) + .where(Session.trainee_id == trainee_id) + .order_by(Session.created_at) + ) + attempts: list[AttemptOut] = [] + competency_sums: dict[str, list[float]] = {} + for session, score in rows: + summary = (score.report or {}).get("summary", {}) if score else {} + attempts.append( + AttemptOut( + session_id=session.id, + scenario_id=session.scenario_id, + mode=session.mode, + attempt=session.attempt, + created_at=session.created_at, + score=score.score_final if score else None, + interview_ms=summary.get("interview_ms"), + facts_got=summary.get("facts_got"), + facts_required=summary.get("facts_required"), + hints=summary.get("hints"), + codes=summary.get("codes", {}), + ) + ) + for item in (score.report or {}).get("competencies", []) if score else []: + competency_sums.setdefault(item["competency"], []).append(item["value"]) + + # Радар — среднее по попыткам: одна неудачная попытка не определяет курсанта. + competencies = { + name: round(sum(values) / len(values), 3) for name, values in competency_sums.items() + } + + deltas: list[DeltaOut] = [] + by_scenario: dict[str, list[AttemptOut]] = {} + for attempt in attempts: + by_scenario.setdefault(attempt.scenario_id, []).append(attempt) + for scenario_id, items in by_scenario.items(): + for previous, current in zip(items, items[1:]): + deltas.append( + DeltaOut( + scenario_id=scenario_id, + from_attempt=previous.attempt, + to_attempt=current.attempt, + score=_diff(previous.score, current.score), + interview_ms=_diff(previous.interview_ms, current.interview_ms), + facts_got=_diff(previous.facts_got, current.facts_got), + ) + ) + + return ProfileOut( + trainee=TraineeOut(id=trainee.id, name=trainee.name, group=group.name if group else None), + attempts=attempts, + competencies=competencies, + deltas=deltas, + hints_total=sum(attempt.hints or 0 for attempt in attempts), + ) + + +def _diff(before, after): + if before is None or after is None: + return None + return round(after - before, 3) diff --git a/backend/app/main.py b/backend/app/main.py index fd02906..5857723 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,6 +9,7 @@ from pathlib import Path from app.api.http import scenarios as scenarios_api from app.api.http import sessions +from app.api.http import trainees from app.api.ws import call as call_ws from app.api.ws import control as control_ws from app.api.ws import observe as observe_ws @@ -60,6 +61,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan) app.include_router(sessions.router) app.include_router(scenarios_api.router) +app.include_router(trainees.router) app.include_router(call_ws.router) app.include_router(observe_ws.router) app.include_router(control_ws.router) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index d022589..4b1c40d 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -11,6 +11,7 @@ import logging from uuid import UUID from app.domain.events import ScoreReady +from app.domain.timers import TimerCode from app.scenarios import store from app.scoring.competency import radar from app.scoring.gost import evaluate @@ -32,8 +33,23 @@ async def finish(session_id: UUID, state) -> None: revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, end_reason=state.end_reason, ) + # Сводка числами: по ней считается дельта между попытками в профиле. + # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. + required = scenario.ground_truth.required_facts + revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else [] + codes: dict[str, int] = {} + for finding in result.findings: + codes[finding.code.value] = codes.get(finding.code.value, 0) + 1 + state.score = { "score_auto": result.score, + "summary": { + "interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW), + "facts_got": len([fact for fact in required if fact in revealed]), + "facts_required": len(required), + "hints": len(state.hints_shown), + "codes": codes, + }, "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)], diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..c8a584e --- /dev/null +++ b/backend/tests/test_profile.py @@ -0,0 +1,88 @@ +"""Профиль курсанта: история попыток и дельта между ними. + +Тест сквозной — занятия проводятся через сокеты с включённым журналом, +профиль читается по HTTP. Без Postgres пропускается. +""" + +import time +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from app.config import get_settings +from app.main import app +from app.session.hub import hub + + +@pytest.fixture(scope="module") +def client(): + # Доступность базы проверяется подключением к порту: синхронного драйвера + # в проекте нет, и попытка проверить им даёт ложный пропуск теста. + import socket + from urllib.parse import urlparse + + url = urlparse(get_settings().database_url) + try: + socket.create_connection((url.hostname or "localhost", url.port or 5432), timeout=2).close() + except OSError as exc: + pytest.skip(f"Postgres недоступен ({exc}) — подними `make dev`") + with TestClient(app) as test_client: + yield test_client + + +def wait_for(predicate, timeout: float = 5.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(0.02) + raise AssertionError("не дождались") + + +def run_lesson(client, trainee: str, address: str | None) -> None: + """Провести занятие: снять трубку, заполнить карточку, завершить, сдать самооценку.""" + session_id = uuid4() + with client.websocket_connect(f"/ws/control/{session_id}") as control: + control.send_json({ + "type": "scenario.start", "scenario_id": "fire-apartment-l2", + "trainee": trainee, "mode": "training", + }) + wait_for(lambda: hub.get(session_id)) + state = hub.get(session_id) + with client.websocket_connect(f"/ws/call/{session_id}") as call: + call.send_json({"type": "call.answer"}) + if address: + call.send_json({"type": "kio.patch", "fields": {"address": address, "dds": "01"}}) + if state.slots: + state.slots.hear("Назовите адрес") + call.send_json({"type": "dds.dispatch", "service": "01"}) + call.send_json({"type": "call.hangup"}) + wait_for(lambda: state.score is not None) + call.send_json({"type": "self_assessment.submit", "missed": [], "comment": ""}) + wait_for(lambda: state.self_assessed) + time.sleep(0.3) # журнал пишет асинхронно + + +def test_profile_shows_attempts_and_delta(client): + name = f"Курсант-{uuid4().hex[:6]}" + run_lesson(client, name, address=None) # первая попытка — ничего не добыл + run_lesson(client, name, address="улица Ленина, 14") # вторая — спросил адрес + + listing = client.get("/api/trainees").json() + trainee = next(item for item in listing if item["name"] == name) + + profile = client.get(f"/api/trainees/{trainee['id']}/profile").json() + assert [item["attempt"] for item in profile["attempts"]] == [1, 2], profile["attempts"] + assert all(item["score"] is not None for item in profile["attempts"]), "оценка попытки не записана" + + assert profile["competencies"], "радар пуст" + assert profile["deltas"], "дельта между попытками не посчитана" + delta = profile["deltas"][0] + assert delta["from_attempt"] == 1 and delta["to_attempt"] == 2 + if delta["facts_got"] is not None: + assert delta["facts_got"] >= 0, "во второй попытке фактов добыто не меньше" + + +def test_profile_of_unknown_trainee_is_404(client): + assert client.get(f"/api/trainees/{uuid4()}/profile").status_code == 404 diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 8ee9e59..bcf6cf5 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom"; import { Call } from "@/pages/trainee/Call"; import { Instructor } from "@/pages/instructor/Instructor"; +import { Profile } from "@/pages/profile/Profile"; import { Wall } from "@/pages/wall/Wall"; import { Stub } from "@/shared/ui/Stub"; @@ -12,5 +13,6 @@ export const router = createBrowserRouter([ { path: "/trainee", element: }, { path: "/instructor", element: }, { path: "/wall", element: }, + { path: "/profile", element: }, { path: "/dds", element: }, ]); diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index 251d0ff..20c16e3 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -185,6 +185,7 @@ export function Instructor() {

История группы

+

профили курсантов

{history.data?.slice(0, 15).map((item) => ( diff --git a/frontend/src/pages/profile/Profile.tsx b/frontend/src/pages/profile/Profile.tsx new file mode 100644 index 0000000..afeab1f --- /dev/null +++ b/frontend/src/pages/profile/Profile.tsx @@ -0,0 +1,120 @@ +// Профиль курсанта: радар компетенций, история попыток, дельта между ними. +// +// «Контроль подготовленности» из ТЗ — не таблица оценок, а инструмент +// планирования подготовки. Работа над ошибками превращается в измеримый цикл: +// посмотрел разбор → попробовал снова → увидел дельту (docs/product/MODES.md). + +import { useState } from "react"; + +import { COMPETENCY_LABELS, Radar } from "@/features/debrief/Radar"; +import { clock } from "@/features/timers/clock"; +import { useProfile, useTrainees } from "@/shared/api/http"; + +const MODE_LABELS: Record = { + training: "тренировочный", + exam: "контрольный", + self: "самостоятельный", +}; + +function sign(value: number | null, format: (value: number) => string): string { + if (value === null) return "—"; + if (value === 0) return "без изменений"; + return `${value > 0 ? "+" : "−"}${format(Math.abs(value))}`; +} + +export function Profile() { + const url = new URLSearchParams(location.search); + const [traineeId, setTraineeId] = useState(url.get("trainee")); + const trainees = useTrainees(); + const profile = useProfile(traineeId); + + return ( +
+

Профиль курсанта

+ +

+ +

+ + {profile.data && ( +
+
+

+ {profile.data.trainee.name} + {profile.data.trainee.group && ` · группа ${profile.data.trainee.group}`} +

+ ({ + competency, + value, + }))} + /> +

+ радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total} +

+ +

Дельта попыток

+
+ + {profile.data.deltas.map((delta, index) => ( + + + + + ))} + {profile.data.deltas.length === 0 && ( + + )} + +
{delta.scenario_id} · {delta.from_attempt} → {delta.to_attempt} + оценка {sign(delta.score, (value) => value.toFixed(0))} + {" · "}опрос {sign(delta.interview_ms, (value) => clock(value))} + {" · "}фактов {sign(delta.facts_got, (value) => String(value))} +
повторных попыток по сценарию ещё не было
+
+ +
+

История занятий

+ + + {profile.data.attempts.map((attempt) => ( + + + + + ))} + {profile.data.attempts.length === 0 && } + +
+ {new Date(attempt.created_at).toLocaleString("ru-RU")} +
{MODE_LABELS[attempt.mode] ?? attempt.mode} · попытка {attempt.attempt}
+
+ {attempt.scenario_id} · оценка {attempt.score?.toFixed(0) ?? "—"} +
+ опрос {attempt.interview_ms === null ? "не завершён" : clock(attempt.interview_ms)} + {attempt.facts_required !== null && ` · факты ${attempt.facts_got}/${attempt.facts_required}`} + {attempt.hints ? ` · подсказок ${attempt.hints}` : ""} + {Object.keys(attempt.codes).length > 0 && + ` · ${Object.entries(attempt.codes).map(([code, count]) => `${code}×${count}`).join(", ")}`} +
+ разбор +
занятий не было
+
+ + )} + + {profile.isError &&

Профиль не загрузился.

} + {!traineeId &&

Курсанты появляются после первого занятия.

} +

+ Компетенции: {Object.values(COMPETENCY_LABELS).join(" · ")} +

+ + ); +} diff --git a/frontend/src/shared/api/http.ts b/frontend/src/shared/api/http.ts index ad00fdd..c4501e9 100644 --- a/frontend/src/shared/api/http.ts +++ b/frontend/src/shared/api/http.ts @@ -70,6 +70,51 @@ export const useSessions = (params: { trainee?: string; group?: string; mode?: S }); }; +export interface TraineeInfo { + id: string; + name: string; + group: string | null; +} + +export interface Attempt { + session_id: string; + scenario_id: string; + mode: SessionMode; + attempt: number; + created_at: string; + score: number | null; + interview_ms: number | null; + facts_got: number | null; + facts_required: number | null; + hints: number | null; + codes: Record; +} + +export interface Profile { + trainee: TraineeInfo; + attempts: Attempt[]; + competencies: Record; + deltas: { + scenario_id: string; + from_attempt: number; + to_attempt: number; + score: number | null; + interview_ms: number | null; + facts_got: number | null; + }[]; + hints_total: number; +} + +export const useTrainees = () => + useQuery({ queryKey: ["trainees"], queryFn: () => request("/api/trainees") }); + +export const useProfile = (traineeId: string | null) => + useQuery({ + queryKey: ["profile", traineeId], + queryFn: () => request(`/api/trainees/${traineeId}/profile`), + enabled: Boolean(traineeId), + }); + export const useCreateSession = () => useMutation({ mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>