lct-18: профиль курсанта — радар, история попыток, дельта

Работа над ошибками становится измеримым циклом: посмотрел разбор →
попробовал снова → увидел дельту по оценке, времени опроса и добытым
фактам.

В отчёт занятия добавлена сводка числами — время опроса, факты,
подсказки, коды ошибок. Без неё дельту пришлось бы вытаскивать разбором
текста метрик («94 с»), а это путь к тихим ошибкам.

Радар — среднее по попыткам: одна неудачная попытка не определяет
курсанта.

Проверено сквозным тестом на живой базе: два занятия одного курсанта
дают две попытки и посчитанную дельту. Проверка доступности базы в тесте
была ложной — синхронного драйвера в проекте нет, и тест молча
пропускался.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:42:12 +03:00
commit 38082e2af3
8 changed files with 412 additions and 0 deletions

View 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)

View file

@ -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)

View file

@ -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)],

View 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

View file

@ -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" /> },
]); ]);

View file

@ -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) => (

View 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>
);
}

View file

@ -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 }) =>