lct-18: профиль курсанта — радар, история попыток, дельта
Работа над ошибками становится измеримым циклом: посмотрел разбор → попробовал снова → увидел дельту по оценке, времени опроса и добытым фактам. В отчёт занятия добавлена сводка числами — время опроса, факты, подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором текста метрик («94 с»), а это путь к тихим ошибкам. Радар — среднее по попыткам: одна неудачная попытка не определяет курсанта. Проверено сквозным тестом на живой базе: два занятия одного курсанта дают две попытки и посчитанную дельту. Проверка доступности базы в тесте была ложной — синхронного драйвера в проекте нет, и тест молча пропускался.
This commit is contained in:
parent
57f4c858b6
commit
38082e2af3
8 changed files with 412 additions and 0 deletions
138
backend/app/api/http/trainees.py
Normal file
138
backend/app/api/http/trainees.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -9,6 +9,7 @@ from pathlib import Path
|
||||||
|
|
||||||
from app.api.http import scenarios as scenarios_api
|
from app.api.http import scenarios as scenarios_api
|
||||||
from app.api.http import sessions
|
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 call as call_ws
|
||||||
from app.api.ws import control as control_ws
|
from app.api.ws import control as control_ws
|
||||||
from app.api.ws import observe as observe_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 = FastAPI(title="Учебный симулятор занятия для системы 112", lifespan=lifespan)
|
||||||
app.include_router(sessions.router)
|
app.include_router(sessions.router)
|
||||||
app.include_router(scenarios_api.router)
|
app.include_router(scenarios_api.router)
|
||||||
|
app.include_router(trainees.router)
|
||||||
app.include_router(call_ws.router)
|
app.include_router(call_ws.router)
|
||||||
app.include_router(observe_ws.router)
|
app.include_router(observe_ws.router)
|
||||||
app.include_router(control_ws.router)
|
app.include_router(control_ws.router)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import ScoreReady
|
from app.domain.events import ScoreReady
|
||||||
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.scoring.competency import radar
|
from app.scoring.competency import radar
|
||||||
from app.scoring.gost import evaluate
|
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,
|
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
||||||
end_reason=state.end_reason,
|
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 = {
|
state.score = {
|
||||||
"score_auto": result.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],
|
"metrics": [metric.model_dump() for metric in result.metrics],
|
||||||
"findings": [finding.model_dump(mode="json") for finding in result.findings],
|
"findings": [finding.model_dump(mode="json") for finding in result.findings],
|
||||||
"competencies": [item.model_dump() for item in radar(result.metrics)],
|
"competencies": [item.model_dump() for item in radar(result.metrics)],
|
||||||
|
|
|
||||||
88
backend/tests/test_profile.py
Normal file
88
backend/tests/test_profile.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
|
|
||||||
import { Call } from "@/pages/trainee/Call";
|
import { Call } from "@/pages/trainee/Call";
|
||||||
import { Instructor } from "@/pages/instructor/Instructor";
|
import { Instructor } from "@/pages/instructor/Instructor";
|
||||||
|
import { Profile } from "@/pages/profile/Profile";
|
||||||
import { Wall } from "@/pages/wall/Wall";
|
import { Wall } from "@/pages/wall/Wall";
|
||||||
import { Stub } from "@/shared/ui/Stub";
|
import { Stub } from "@/shared/ui/Stub";
|
||||||
|
|
||||||
|
|
@ -12,5 +13,6 @@ export const router = createBrowserRouter([
|
||||||
{ path: "/trainee", element: <Call /> },
|
{ path: "/trainee", element: <Call /> },
|
||||||
{ path: "/instructor", element: <Instructor /> },
|
{ path: "/instructor", element: <Instructor /> },
|
||||||
{ path: "/wall", element: <Wall /> },
|
{ path: "/wall", element: <Wall /> },
|
||||||
|
{ path: "/profile", element: <Profile /> },
|
||||||
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
{ path: "/dds", element: <Stub title="АРМ диспетчера ДДС" card="lct-20" /> },
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,7 @@ export function Instructor() {
|
||||||
|
|
||||||
<section style={{ flex: "0 0 260px" }}>
|
<section style={{ flex: "0 0 260px" }}>
|
||||||
<h2>История группы</h2>
|
<h2>История группы</h2>
|
||||||
|
<p><a href="/profile">профили курсантов</a></p>
|
||||||
<table className="grid">
|
<table className="grid">
|
||||||
<tbody>
|
<tbody>
|
||||||
{history.data?.slice(0, 15).map((item) => (
|
{history.data?.slice(0, 15).map((item) => (
|
||||||
|
|
|
||||||
120
frontend/src/pages/profile/Profile.tsx
Normal file
120
frontend/src/pages/profile/Profile.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<string | null>(url.get("trainee"));
|
||||||
|
const trainees = useTrainees();
|
||||||
|
const profile = useProfile(traineeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page">
|
||||||
|
<h1>Профиль курсанта</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<select value={traineeId ?? ""} onChange={(event) => setTraineeId(event.target.value || null)}>
|
||||||
|
<option value="">— выберите курсанта —</option>
|
||||||
|
{trainees.data?.map((trainee) => (
|
||||||
|
<option key={trainee.id} value={trainee.id}>
|
||||||
|
{trainee.name}{trainee.group ? ` · ${trainee.group}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{profile.data && (
|
||||||
|
<div className="columns">
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
{profile.data.trainee.name}
|
||||||
|
{profile.data.trainee.group && ` · группа ${profile.data.trainee.group}`}
|
||||||
|
</h2>
|
||||||
|
<Radar
|
||||||
|
values={Object.entries(profile.data.competencies).map(([competency, value]) => ({
|
||||||
|
competency,
|
||||||
|
value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<p className="ref">
|
||||||
|
радар — среднее по попыткам; подсказок использовано всего: {profile.data.hints_total}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Дельта попыток</h3>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{profile.data.deltas.map((delta, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<th>{delta.scenario_id} · {delta.from_attempt} → {delta.to_attempt}</th>
|
||||||
|
<td>
|
||||||
|
оценка {sign(delta.score, (value) => value.toFixed(0))}
|
||||||
|
{" · "}опрос {sign(delta.interview_ms, (value) => clock(value))}
|
||||||
|
{" · "}фактов {sign(delta.facts_got, (value) => String(value))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{profile.data.deltas.length === 0 && (
|
||||||
|
<tr><td colSpan={2}>повторных попыток по сценарию ещё не было</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>История занятий</h2>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{profile.data.attempts.map((attempt) => (
|
||||||
|
<tr key={attempt.session_id}>
|
||||||
|
<th>
|
||||||
|
{new Date(attempt.created_at).toLocaleString("ru-RU")}
|
||||||
|
<div className="ref">{MODE_LABELS[attempt.mode] ?? attempt.mode} · попытка {attempt.attempt}</div>
|
||||||
|
</th>
|
||||||
|
<td>
|
||||||
|
{attempt.scenario_id} · оценка {attempt.score?.toFixed(0) ?? "—"}
|
||||||
|
<div className="ref">
|
||||||
|
опрос {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(", ")}`}
|
||||||
|
</div>
|
||||||
|
<a href={`/wall?session=${attempt.session_id}`}>разбор</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{profile.data.attempts.length === 0 && <tr><td colSpan={2}>занятий не было</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile.isError && <p className="violated">Профиль не загрузился.</p>}
|
||||||
|
{!traineeId && <p className="ref">Курсанты появляются после первого занятия.</p>}
|
||||||
|
<p className="ref">
|
||||||
|
Компетенции: {Object.values(COMPETENCY_LABELS).join(" · ")}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
trainee: TraineeInfo;
|
||||||
|
attempts: Attempt[];
|
||||||
|
competencies: Record<string, number>;
|
||||||
|
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<TraineeInfo[]>("/api/trainees") });
|
||||||
|
|
||||||
|
export const useProfile = (traineeId: string | null) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["profile", traineeId],
|
||||||
|
queryFn: () => request<Profile>(`/api/trainees/${traineeId}/profile`),
|
||||||
|
enabled: Boolean(traineeId),
|
||||||
|
});
|
||||||
|
|
||||||
export const useCreateSession = () =>
|
export const useCreateSession = () =>
|
||||||
useMutation({
|
useMutation({
|
||||||
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
mutationFn: (body: { scenario_id: string; mode: SessionMode; trainee?: string; group?: string }) =>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue