From 0526b11f9143a66ad9423d33cf91ebf7952abf1f Mon Sep 17 00:00:00 2001 From: andreysk0304 Date: Mon, 21 Sep 2026 19:56:13 +0300 Subject: [PATCH] Implement DDS exercise, customer UI and local demo --- Makefile | 6 +- backend/app/api/auth.py | 111 +++++- backend/app/api/http/admin.py | 37 +- backend/app/api/http/groups.py | 125 +++++++ backend/app/api/http/sessions.py | 11 +- backend/app/api/http/trainees.py | 18 +- backend/app/api/ws/control.py | 76 +++-- backend/app/api/ws/station.py | 50 ++- backend/app/config.py | 3 + backend/app/db/base.py | 3 + backend/app/db/repo.py | 2 +- backend/app/domain/events.py | 27 ++ backend/app/domain/statuses.py | 12 + backend/app/main.py | 31 +- backend/app/scenarios/schema.py | 13 + backend/app/scoring/group.py | 69 ++++ backend/app/scoring/report.py | 11 +- backend/app/scoring/taxonomy.py | 1 + backend/app/scoring/weights.py | 10 + backend/app/session/dds.py | 50 +++ backend/app/session/finish.py | 95 +++++- backend/app/session/journal.py | 18 + backend/app/session/state.py | 35 ++ backend/tests/test_auth_hardening.py | 167 +++++++++ backend/tests/test_dds_exercise.py | 121 +++++++ backend/tests/test_demo_no_db.py | 75 ++++ backend/tests/test_group_analytics.py | 75 ++++ backend/tests/test_score_weights.py | 39 +++ backend/tests/test_ws.py | 29 +- frontend/src/app/router.tsx | 5 +- frontend/src/features/auth/Login.tsx | 45 ++- frontend/src/features/auth/useAuth.ts | 19 +- frontend/src/features/health/StandStatus.tsx | 4 + .../features/instructor/ScenarioEditor.tsx | 2 +- .../src/features/instructor/useControl.ts | 3 +- frontend/src/features/kio-card/KioCard.tsx | 123 ++++--- frontend/src/features/kio-card/fields.ts | 22 +- frontend/src/pages/dds/Dds.tsx | 301 +++++++++++++--- frontend/src/pages/groups/Groups.tsx | 94 +++++ frontend/src/pages/instructor/Instructor.tsx | 26 +- frontend/src/pages/trainee/Call.tsx | 123 +++---- frontend/src/shared/api/http.ts | 57 +++- frontend/src/shared/types/generated.ts | 41 ++- frontend/src/styles.css | 320 ++++++++++++++++++ scripts/load_dds.py | 228 +++++++++++++ tests/test_load_dds.py | 26 ++ 46 files changed, 2505 insertions(+), 254 deletions(-) create mode 100644 backend/app/api/http/groups.py create mode 100644 backend/app/scoring/group.py create mode 100644 backend/app/scoring/weights.py create mode 100644 backend/app/session/dds.py create mode 100644 backend/tests/test_auth_hardening.py create mode 100644 backend/tests/test_demo_no_db.py create mode 100644 backend/tests/test_group_analytics.py create mode 100644 backend/tests/test_score_weights.py create mode 100644 frontend/src/pages/groups/Groups.tsx create mode 100644 scripts/load_dds.py create mode 100644 tests/test_load_dds.py diff --git a/Makefile b/Makefile index 5cef303..544ce03 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ UV ?= uv # молча ждёт блокировку. Один раз поставить оба набора: # cd backend && uv sync --extra dev --extra voice COMPOSE ?= docker compose +DEMO_PORT ?= 8112 .DEFAULT_GOAL := help @@ -83,4 +84,7 @@ pregen: ## Офлайн-таблицы реплик: make pregen [s=<сцена demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт ./scripts/demo.sh -.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo +demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети + cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1 + +.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo demo-lite diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a11d797..0f8e2df 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -15,7 +15,10 @@ протокола авторизации в канале нет. """ +import asyncio import logging +import secrets +import weakref from uuid import UUID from argon2 import PasswordHasher @@ -31,8 +34,58 @@ from app.domain.roles import Role log = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth", tags=["auth"]) +DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112") _hasher = PasswordHasher() +# Cookie is signed, but carries a role snapshot. A changed account must not +# keep its old privileges for the full 12-hour cookie lifetime. Compose runs +# one worker; the process marker also invalidates all cookies after restart. +_INSTANCE = secrets.token_urlsafe(32) +_generations: dict[str, int] = {} +_active_sockets: dict[str, weakref.WeakKeyDictionary] = {} + + +async def _close_revoked(ws: WebSocket) -> None: + try: + await ws.close(code=1008, reason="Учётная запись изменена: войдите снова") + except (RuntimeError, OSError): + # The peer may already have disconnected; revocation still stands. + pass + + +def invalidate_login(login: str) -> None: + """Revoke previously issued cookies after account/role/password changes.""" + _generations[login] = _generations.get(login, 0) + 1 + for ws, loop in list(_active_sockets.get(login, {}).items()): + try: + if not loop.is_closed(): + loop.call_soon_threadsafe(lambda socket=ws: asyncio.create_task(_close_revoked(socket))) + except RuntimeError: + pass # loop closed between the check and scheduling + + +def _session_principal(session: dict) -> "Principal | None": + data = session.get("principal") + if not isinstance(data, dict): + return None + login = data.get("login") + if not isinstance(login, str): + return None + if session.get("auth_instance") != _INSTANCE: + return None + if session.get("auth_generation") != _generations.get(login, 0): + return None + try: + return Principal.model_validate(data) + except Exception: # malformed signed cookie: reject, do not 500 + log.warning("неверный формат principal в cookie") + return None + + +def _issue_session(request: Request, who: "Principal") -> None: + request.session["principal"] = who.model_dump(mode="json") + request.session["auth_instance"] = _INSTANCE + request.session["auth_generation"] = _generations.get(who.login, 0) def hash_password(password: str) -> str: @@ -50,8 +103,7 @@ def verify_password(password_hash: str, password: str) -> bool: class Principal(BaseModel): - """Кто действует. Хранится в cookie целиком: ходить в базу за ролью - на каждый запрос незачем, а меняется она правкой учётной записи.""" + """Кто действует. Cookie stores a snapshot, revoked on account edits.""" login: str full_name: str @@ -69,9 +121,23 @@ class LoginIn(BaseModel): password: str +def _demo_local(request: Request) -> None: + # При ошибочном bind 0.0.0.0 удалённый клиент всё равно не получит cookie. + if request.client is None or request.client.host not in {"127.0.0.1", "::1", "localhost", "testclient"}: + raise HTTPException(status_code=403, detail="demo_local_only") + + +def _demo_principal(role: Role) -> Principal: + return Principal( + login="demo-trainee" if role is Role.TRAINEE else "demo-instructor", + full_name="Демо-курсант" if role is Role.TRAINEE else "Демо-преподаватель", + role=role, + trainee_id=DEMO_TRAINEE_ID if role is Role.TRAINEE else None, + ) + + def current(request: Request) -> Principal | None: - data = request.session.get("principal") - return Principal.model_validate(data) if data else None + return _session_principal(request.session) def principal_of(websocket: WebSocket) -> Principal | None: @@ -80,8 +146,10 @@ def principal_of(websocket: WebSocket) -> Principal | None: `WebSocket.session` доступен, потому что `SessionMiddleware` стоит до роутера: значит роль известна до входа в цикл приёма сообщений. """ - data = websocket.session.get("principal") if "session" in websocket.scope else None - return Principal.model_validate(data) if data else None + who = _session_principal(websocket.session) if "session" in websocket.scope else None + if who is not None: + _active_sockets.setdefault(who.login, weakref.WeakKeyDictionary())[websocket] = asyncio.get_running_loop() + return who def require(request: Request, *roles: Role) -> Principal: @@ -99,6 +167,8 @@ async def audit( ) -> None: """Запись в журнал. Аудит не должен ронять действие: если база недоступна, занятие продолжается, а пропуск виден в логе.""" + if get_settings().demo_no_db: + return # в явном demo-режиме запись и долговременный аудит недоступны try: async with get_sessionmaker()() as db: db.add( @@ -113,6 +183,16 @@ async def audit( @router.post("/login") async def login(payload: LoginIn, request: Request) -> dict: + if get_settings().demo_no_db: + _demo_local(request) + demo_roles = {"demo-instructor": Role.INSTRUCTOR, "demo-trainee": Role.TRAINEE} + role = demo_roles.get(payload.login) + if role is None or not secrets.compare_digest(payload.password, "demo"): + raise HTTPException(status_code=401, detail="bad_credentials") + who = _demo_principal(role) + _issue_session(request, who) + return who.model_dump(mode="json") + async with get_sessionmaker()() as db: user = await db.scalar(select(User).where(User.login == payload.login)) @@ -130,7 +210,7 @@ async def login(payload: LoginIn, request: Request) -> dict: service=user.service, trainee_id=user.trainee_id, ) - request.session["principal"] = who.model_dump(mode="json") + _issue_session(request, who) await audit(who.login, who.role.value, "login") return who.model_dump(mode="json") @@ -153,7 +233,7 @@ async def me(request: Request) -> dict: @router.post("/dev-token") -async def dev_token(request: Request) -> dict: +async def dev_token(request: Request, role: Role = Role.INSTRUCTOR) -> dict: """Вход без пароля для наших же инструментов. `make lesson` и тесты открывают сокеты напрямую и после включения ролей @@ -161,8 +241,17 @@ async def dev_token(request: Request) -> dict: умолчанию выключенным: в рабочем стенде она отвечает 404, а не 401 — выключенной функции не должно быть видно вовсе. """ - if not get_settings().dev_auth_bypass: + settings = get_settings() + if not settings.dev_auth_bypass: raise HTTPException(status_code=404, detail="not_found") - who = Principal(login="dev", full_name="Разработка", role=Role.INSTRUCTOR) - request.session["principal"] = who.model_dump(mode="json") + if role is not Role.INSTRUCTOR and not settings.demo_no_db: + raise HTTPException(status_code=404, detail="not_found") + if settings.demo_no_db: + _demo_local(request) + if role not in {Role.INSTRUCTOR, Role.TRAINEE}: + raise HTTPException(status_code=403, detail="demo_role_forbidden") + who = _demo_principal(role) if settings.demo_no_db else Principal( + login="dev", full_name="Разработка", role=Role.INSTRUCTOR + ) + _issue_session(request, who) return who.model_dump(mode="json") diff --git a/backend/app/api/http/admin.py b/backend/app/api/http/admin.py index 069185f..e8be34c 100644 --- a/backend/app/api/http/admin.py +++ b/backend/app/api/http/admin.py @@ -10,6 +10,7 @@ """ import logging +import re from datetime import datetime from uuid import UUID @@ -18,9 +19,10 @@ from pydantic import BaseModel, Field from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from starlette.concurrency import run_in_threadpool from app.admin import backup as backup_service -from app.api.auth import audit, hash_password, require +from app.api.auth import audit, hash_password, invalidate_login, require from app.config import get_settings from app.db.base import get_session from app.db.models import AuditLog, Session as SessionRow, Trainee, User @@ -122,6 +124,13 @@ async def patch_user( changed: list[str] = [] if body.role is not None: + if user.login == who.login and body.role is not Role.ADMIN: + raise HTTPException(status_code=409, detail="cannot_demote_yourself") + if body.role is Role.TRAINEE and user.trainee_id is None: + trainee = Trainee(name=user.full_name) + db.add(trainee) + await db.flush() + user.trainee_id = trainee.id user.role = body.role.value changed.append(f"роль {body.role.value}") if body.service is not None: @@ -137,7 +146,10 @@ async def patch_user( user.password_hash = hash_password(body.password) changed.append("пароль сброшен") + if not changed: + return _out(user) await db.commit() + invalidate_login(user.login) await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed)) return _out(user) @@ -162,7 +174,7 @@ async def audit_log( """Журнал действий. Администратор его читает, но не правит: точки удаления или изменения записи здесь нет — ТЗ требует хранения, а не управления.""" require(request, Role.ADMIN) - query = select(AuditLog).order_by(AuditLog.at.desc()).limit(min(limit, 1000)) + query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000))) if action: query = query.where(AuditLog.action == action) if actor: @@ -261,7 +273,19 @@ class BackupOut(BaseModel): @router.get("/backups", response_model=list[BackupOut]) async def backups(request: Request) -> list[BackupOut]: require(request, Role.ADMIN) - return [BackupOut(**item) for item in backup_service.listing()] + return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)] + + +def _safe_backup_error(exc: backup_service.BackupError) -> str: + """Never echo DATABASE_URL/its password from backup diagnostics to HTTP.""" + message = str(exc) + dsn = get_settings().database_url + if dsn: + message = message.replace(dsn, "[DATABASE_URL скрыт]") + match = re.search(r"://[^:]+:([^@]+)@", dsn) + if match and match.group(1): + message = message.replace(match.group(1), "[пароль скрыт]") + return message @router.post("/backups", response_model=BackupOut, status_code=201) @@ -270,8 +294,11 @@ async def make_backup(request: Request) -> BackupOut: кнопка нужна перед занятием, расписание — чтобы о нём не вспоминали.""" who = require(request, Role.ADMIN) try: - created = backup_service.create() + # pg_dump may run for two minutes; never block the event loop for it. + created = await run_in_threadpool(backup_service.create) except backup_service.BackupError as exc: - raise HTTPException(status_code=503, detail=str(exc)) from exc + detail = _safe_backup_error(exc) + await audit(who.login, who.role.value, "backup.failed", detail=detail) + raise HTTPException(status_code=503, detail=detail) from exc await audit(who.login, who.role.value, "backup.create", created["name"]) return BackupOut(**created) diff --git a/backend/app/api/http/groups.py b/backend/app/api/http/groups.py new file mode 100644 index 0000000..55b8f07 --- /dev/null +++ b/backend/app/api/http/groups.py @@ -0,0 +1,125 @@ +"""Сводка ошибок и рекомендаций учебной группы для преподавателя.""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field +from sqlalchemy import and_, func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.auth import audit, require +from app.db.base import get_session +from app.db.models import Group, Score, Session, Trainee +from app.domain.roles import Role +from app.scoring.group import ScoredAttempt, summarize + +router = APIRouter(prefix="/api/groups", tags=["groups"]) + + +class GroupOut(BaseModel): + id: UUID + name: str + + +class GroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class GroupErrorOut(BaseModel): + code: str + title: str + affected_trainees: int + occurrences: int + rate_percent: float + recommendation: str + + +class GroupAnalyticsOut(BaseModel): + group: GroupOut + enrolled_trainees: int + active_trainees: int + scored_attempts: int + average_score: float | None + errors: list[GroupErrorOut] + + +@router.get("", response_model=list[GroupOut]) +async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]: + require(request, Role.INSTRUCTOR, Role.ADMIN) + groups = await db.scalars(select(Group).order_by(Group.name)) + return [GroupOut(id=group.id, name=group.name) for group in groups] + + +@router.post("", response_model=GroupOut, status_code=201) +async def create( + body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session) +) -> GroupOut: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + name = body.name.strip() + if not name: + raise HTTPException(status_code=422, detail="group_name_required") + group = Group(name=name) + db.add(group) + try: + await db.commit() + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="group_exists") from exc + await audit(who.login, who.role.value, "group.create", str(group.id), group.name) + return GroupOut(id=group.id, name=group.name) + + +@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut) +async def assign_trainee( + group_id: UUID, trainee_id: UUID, request: Request, + db: AsyncSession = Depends(get_session), +) -> GroupOut: + who = require(request, Role.INSTRUCTOR, Role.ADMIN) + group = await db.get(Group, group_id) + if group is None: + raise HTTPException(status_code=404, detail="group_not_found") + trainee = await db.get(Trainee, trainee_id) + if trainee is None: + raise HTTPException(status_code=404, detail="trainee_not_found") + trainee.group_id = group_id + await db.commit() + await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id)) + return GroupOut(id=group.id, name=group.name) + + +@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut) +async def analytics( + group_id: UUID, request: Request, db: AsyncSession = Depends(get_session) +) -> GroupAnalyticsOut: + require(request, Role.INSTRUCTOR, Role.ADMIN) + group = await db.get(Group, group_id) + if group is None: + raise HTTPException(status_code=404, detail="group_not_found") + enrolled = await db.scalar( + select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id) + ) + rows = await db.execute( + select(Session.trainee_id, Score.score_final, Score.report) + .join(Score, Score.session_id == Session.id) + .join(Trainee, Trainee.id == Session.trainee_id, isouter=True) + .where( + or_( + Session.group_id == group_id, + and_(Session.group_id.is_(None), Trainee.group_id == group_id), + ), + Session.ended_at.is_not(None), + ) + ) + attempts = [ + ScoredAttempt( + trainee_id=trainee_id, + score=score, + codes=(report or {}).get("summary", {}).get("codes", {}), + ) + for trainee_id, score, report in rows + ] + return GroupAnalyticsOut( + group=GroupOut(id=group.id, name=group.name), + **summarize(attempts, int(enrolled or 0)), + ) diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index a39e10a..ffdd88a 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -8,7 +8,7 @@ from datetime import datetime from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response -from pydantic import BaseModel +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.api.auth import audit, require @@ -117,9 +117,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut class ScoreOverride(BaseModel): """Коррекция оценки преподавателем. Автооценка сохраняется рядом.""" - score_final: float + score_final: float = Field(ge=0, le=100) comment: str = "" - author: str = "преподаватель" def _live(session_id: UUID): @@ -188,9 +187,13 @@ async def override(session_id: UUID, body: ScoreOverride, request: Request) -> S state.score = { **state.score, "score_final": body.score_final, - "overridden_by": body.author, + "overridden_by": who.login, "override_comment": body.comment, } + if hub.journal: + await hub.journal.score_override( + session_id, body.score_final, who.login, body.comment + ) await audit( who.login, who.role.value, "score.override", str(session_id), f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}", diff --git a/backend/app/api/http/trainees.py b/backend/app/api/http/trainees.py index 64a1d09..882afa0 100644 --- a/backend/app/api/http/trainees.py +++ b/backend/app/api/http/trainees.py @@ -13,9 +13,10 @@ from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.auth import require +from app.api.auth import DEMO_TRAINEE_ID, require +from app.config import get_settings from app.domain.roles import Role -from app.db.base import get_session +from app.db.base import get_session, get_sessionmaker from app.db.models import Group, Score, Session, Trainee router = APIRouter(prefix="/api/trainees", tags=["trainees"]) @@ -61,14 +62,17 @@ class ProfileOut(BaseModel): @router.get("", response_model=list[TraineeOut]) -async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[TraineeOut]: +async def listing(request: Request) -> list[TraineeOut]: """Список курсантов — преподавателю и администратору: обучающемуся он не нужен, а чужие фамилии из него видны.""" require(request, Role.INSTRUCTOR, Role.ADMIN) - 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] + if get_settings().demo_no_db: + return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")] + async with get_sessionmaker()() as db: + 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) diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index d1b5961..fd8ef96 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -9,7 +9,7 @@ """ import logging -import re +import math from uuid import UUID, uuid4 from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -44,7 +44,7 @@ from app.api.auth import audit, principal_of from app.domain.roles import Role from app.session.hub import hub from app.session.state import SessionState, now_utc -from app.domain.kio import KIO, ResponseStatus +from app.session.dds import prepare_card from app.voice.models import get_voice_models from app.voice.pipeline import FILLERS, prefetch @@ -84,6 +84,23 @@ async def _start(session_id: UUID, event, who=None) -> None: ) return + scenario_ids = event.scenario_ids or [event.scenario_id] + if event.exercise is Exercise.DDS: + if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.SCENARIO_INVALID, + message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек", + )) + return + scenarios = [store.get(scenario_id) for scenario_id in scenario_ids] + if any(item is None for item in scenarios): + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.SCENARIO_INVALID, message="В очереди ДДС есть неизвестный сценарий", + )) + return + else: + scenarios = [] + attempt = 1 recorded_trainee_id = event.trainee_id if hub.journal: @@ -114,29 +131,8 @@ async def _start(session_id: UUID, event, who=None) -> None: state.persona = PersonaState(state.scenario.persona) state.caller = build_caller(scenario.id) elif event.exercise is Exercise.DDS: - # В ДДС поступает уже оформленная учебная карточка. Содержимое берётся - # из утверждённого сценария, а не из действий несуществующего оператора. - truth = scenario.ground_truth - address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "") - floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE) - service = truth.dds.value if truth.dds else None - fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"} - state.kio = KIO( - registered_at=now_utc(), caller_number="+7 (495) 000-00-00", - address=truth.address or address_fact or None, - floor=floor.group(1) if floor else None, - incident_type=truth.incident_type, incident_code=truth.incident_code, - dds=truth.dds, signs=list(scenario.signs), - notify=list(truth.notify) or ([fallback[service]] if service in fallback else []), - victims_count=truth.victims, - description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line, - ) - if service: - state.dispatch(service) - else: - state.kio.response_status = ResponseStatus.TRANSFERRED - state.dispatched_card = state.kio.model_copy(deep=True) - state.dispatched_at = now_utc() + state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios] + prepare_card(state, state.dds_scenarios[0]) state.started_at = state.dispatched_at else: state.started_at = now_utc() @@ -151,8 +147,6 @@ async def _start(session_id: UUID, event, who=None) -> None: if models is not None: asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()])) state.on_event("call.incoming") - elif event.exercise is Exercise.DDS: - state.on_event("dds.dispatch") hub.start_ticker(session_id) if who is not None: # Запуск занятия меняет чужой результат — значит попадает в журнал @@ -265,15 +259,39 @@ async def control(ws: WebSocket, session_id: UUID) -> None: if state is not None: hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id)) case "score.override": + if event.session_id != session_id: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.FORBIDDEN, + message="Оценка относится к другому занятию", + )) + continue state = hub.get(session_id) if state is not None and state.score is not None: + try: + verdict = float(event.verdict) + except ValueError: + verdict = float("nan") + if not math.isfinite(verdict) or not 0 <= verdict <= 100: + hub.to_observers(session_id, ErrorEvent( + code=ErrorKind.UNSUPPORTED_EVENT, + message="Оценка должна быть числом от 0 до 100", + )) + continue # Автооценка остаётся рядом: видно, что скорректировано и кем. state.score = { **state.score, - "score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"], - "overridden_by": "преподаватель", + "score_final": verdict, + "overridden_by": who.login, "override_comment": event.comment, } + if hub.journal: + await hub.journal.score_override( + session_id, verdict, who.login, event.comment + ) + await audit( + who.login, who.role.value, "score.override", str(session_id), + f"{state.score.get('score_auto')} → {verdict}: {event.comment}", + ) hub.to_observers(session_id, ScoreReady(session_id=session_id)) case "director.inject": state = hub.get(session_id) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 83c687a..38075d5 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -25,6 +25,8 @@ from app.domain.roles import Role from app.session.hub import hub from app.session.state import now_utc from app.session.finish import finish +from app.session.finish import score_current_dds +from app.session.dds import prepare_card log = logging.getLogger(__name__) router = APIRouter() @@ -50,6 +52,18 @@ def _error(session_id: UUID, message: str) -> None: hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message)) +async def _finish_dds(session_id: UUID, state) -> None: + state.ended_at = now_utc() + state.end_reason = CallEndReason.COMPLETE + hub.stop_ticker(session_id) + hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) + hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) + if hub.journal: + await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value) + await finish(session_id, state) + hub.to_station(session_id, ScoreReady(session_id=session_id)) + + async def _handle(session_id: UUID, state, event) -> None: if state.ended: _error(session_id, "Занятие уже завершено") @@ -104,6 +118,7 @@ async def _handle(session_id: UUID, state, event) -> None: return state.crew_selected = event.crew state.crew_assignments[service] = event.crew + state.dds_log.append(("crew.select", now_utc(), event.crew)) case "phone.dial": crew = state.crew_selected service = state.crew_service(crew) if crew else None @@ -126,20 +141,37 @@ async def _handle(session_id: UUID, state, event) -> None: text=REPORT_TEXT[phase], at=now_utc(), ) state.phone_reports.append(report) + state.dds_log.append(("phone.dial", now_utc(), crew)) hub.to_station(session_id, PhoneReport(**report.model_dump())) + case "card.reply": + if state.exercise is not Exercise.DDS or not state.dispatched_card or ( + event.card_id != state.dispatched_card.card_id + ): + _error(session_id, "Ответ относится не к текущей карточке") + return + state.reply_text = event.text + state.reply_log.append((now_utc(), event.text)) + case "card.next": + if state.exercise is not Exercise.DDS or not state.dispatched_card or ( + event.card_id != state.dispatched_card.card_id + ): + _error(session_id, "Следующая карточка недоступна: ID текущей не совпадает") + return + if any(item.card_id == event.card_id for item in state.dds_completed): + _error(session_id, "Эта карточка уже завершена") + return + state.dds_completed.append(score_current_dds(state)) + if state.dds_card_index + 1 < len(state.dds_scenarios): + state.dds_card_index += 1 + prepare_card(state, state.dds_scenarios[state.dds_card_index]) + hub.to_station(session_id, state.card_received_event()) + else: + await _finish_dds(session_id, state) case "station.finish": if state.exercise is not Exercise.DDS: _error(session_id, "Операторское занятие завершается после звонка 112") return - state.ended_at = now_utc() - state.end_reason = CallEndReason.COMPLETE - hub.stop_ticker(session_id) - hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) - hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) - if hub.journal: - await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value) - await finish(session_id, state) - hub.to_station(session_id, ScoreReady(session_id=session_id)) + await _finish_dds(session_id, state) case "card.bounce": # Карточка вернулась: в разборе это E6 с конкретной причиной. state.bounced_fields = list(event.missing_fields) diff --git a/backend/app/config.py b/backend/app/config.py index 706864e..f7143c1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,6 +13,9 @@ class Settings(BaseSettings): # Данные database_url: str = "postgresql+asyncpg://lct:lct@localhost:5432/lct" + # Только локальная демонстрация: живые занятия и отчёты в памяти, без + # Postgres и без долговременного журнала. Не включать на учебном стенде. + demo_no_db: bool = False # Голосовой контур models_dir: str = "models" diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 6c5d3ad..39d12b5 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -2,6 +2,7 @@ from collections.abc import AsyncIterator +from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase @@ -45,5 +46,7 @@ def reset() -> None: async def get_session() -> AsyncIterator[AsyncSession]: """Зависимость FastAPI: сессия на запрос, коммит явный.""" + if get_settings().demo_no_db: + raise HTTPException(status_code=503, detail="database_disabled_demo") async with get_sessionmaker()() as session: yield session diff --git a/backend/app/db/repo.py b/backend/app/db/repo.py index 15b7b14..4d921c1 100644 --- a/backend/app/db/repo.py +++ b/backend/app/db/repo.py @@ -75,7 +75,7 @@ async def ensure_session( scenario_id=scenario_id, mode=mode, trainee_id=trainee.id if trainee else None, - group_id=group.id if group else None, + group_id=group.id if group else trainee.group_id if trainee else None, session_id=session_id, ) diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py index b644e41..03c8cc3 100644 --- a/backend/app/domain/events.py +++ b/backend/app/domain/events.py @@ -418,6 +418,7 @@ ServerToObserver = Annotated[ class ScenarioStart(BaseModel): type: Literal["scenario.start"] = "scenario.start" scenario_id: str + scenario_ids: list[str] | None = None trainee: str trainee_id: UUID | None = None group_id: str | None = None @@ -483,6 +484,8 @@ class CardReceived(BaseModel): card: KIO from_operator: str at: datetime + card_index: int = 1 + card_total: int = 1 class CardAck(BaseModel): @@ -535,6 +538,17 @@ class StationFinish(BaseModel): type: Literal["station.finish"] = "station.finish" +class CardReply(BaseModel): + type: Literal["card.reply"] = "card.reply" + card_id: UUID + text: str = Field(max_length=2000) + + +class CardNext(BaseModel): + type: Literal["card.next"] = "card.next" + card_id: UUID + + class StationState(BaseModel): """Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше.""" @@ -564,6 +578,7 @@ ServerToStation = Annotated[ StationToServer = Annotated[ CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish + | CardReply | CardNext | ZoneDecision | CrewDispatched | CrewArrived, Field(discriminator="type"), ] @@ -616,6 +631,17 @@ class SelfAssessmentDiff(BaseModel): overcautious: list[str] = [] +class DdsCardReport(BaseModel): + card_id: UUID + scenario_id: str + score_auto: float + reply_text: str + metrics: list[Metric] + findings: list[Finding] + actions: list[dict[str, Any]] = [] + duration_ms: int = 0 + + class SessionReport(BaseModel): """Единица истории: из отчётов складываются профиль, дельта попыток, аналитика.""" @@ -626,6 +652,7 @@ class SessionReport(BaseModel): transcript: list[TranscriptEntry] findings: list[Finding] metrics: list[Metric] + card_results: list[DdsCardReport] = [] competencies: list[CompetencyScore] reference_questions: list[HintShown] missed_checklist: list[str] diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py index c3c19db..3993afe 100644 --- a/backend/app/domain/statuses.py +++ b/backend/app/domain/statuses.py @@ -11,6 +11,7 @@ from datetime import datetime from enum import StrEnum +from uuid import UUID from pydantic import BaseModel @@ -134,6 +135,12 @@ class PhoneReportRecord(BaseModel): at: datetime +class DdsCardSummary(BaseModel): + card_id: UUID + scenario_id: str + score_auto: float + + class StatusError(ValueError): """Переход запрещён автоматом или нет обязательного комментария.""" @@ -208,3 +215,8 @@ class StationSnapshot(BaseModel): crew_options: list[str] = [] crew_selected: str | None = None phone_reports: list[PhoneReportRecord] = [] + card_id: UUID | None = None + card_index: int = 1 + card_total: int = 1 + reply_text: str = "" + completed_cards: list[DdsCardSummary] = [] diff --git a/backend/app/main.py b/backend/app/main.py index 1945bb4..5c7b692 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,6 +13,7 @@ from pathlib import Path from app.api import auth from app.api.http import admin as admin_api from app.api.http import ekp as ekp_api +from app.api.http import groups as groups_api from app.api.http import scenarios as scenarios_api from app.api.http import sessions from app.api.http import trainees @@ -41,6 +42,9 @@ logging.getLogger("app").setLevel(logging.INFO) @asynccontextmanager async def lifespan(app: FastAPI): + settings = get_settings() + if settings.demo_no_db and not settings.dev_auth_bypass: + raise RuntimeError("DEMO_NO_DB требует DEV_AUTH_BYPASS=true для локального входа") # Библиотека проверяется на старте целиком: сломанный сценарий, найденный # посреди занятия, — сценарий, которого не должно случиться. try: @@ -51,22 +55,27 @@ async def lifespan(app: FastAPI): # Утверждённые преподавателем сценарии хранятся в БД и должны переживать # перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека. - try: - # БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем - # старт АРМ на минуту ради необязательной пользовательской библиотеки. - async with asyncio.timeout(1): - async with get_sessionmaker()() as db: - app.state.scenarios_loaded += await store.restore_published(db) - except (SQLAlchemyError, OSError, TimeoutError) as exc: + if not settings.demo_no_db: + try: + # БД может ещё подниматься или отсутствовать в unit-тесте. Не задерживаем + # старт АРМ на минуту ради необязательной пользовательской библиотеки. + async with asyncio.timeout(1): + async with get_sessionmaker()() as db: + app.state.scenarios_loaded += await store.restore_published(db) + except (SQLAlchemyError, OSError, TimeoutError) as exc: + logging.getLogger(__name__).warning( + "не удалось восстановить утверждённые сценарии из БД: %s", exc + ) + else: logging.getLogger(__name__).warning( - "не удалось восстановить утверждённые сценарии из БД: %s", exc + "DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен" ) # Журнал: всё, что не записано, для оценки не существует. - hub.journal = DbJournal(get_sessionmaker()) + hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker()) # Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия. - app.state.embeddings_ready = get_embedder() is not None + app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None # Модели речи: ~5 секунд на старте стенда вместо паузы на первом звонке. app.state.models_ready = get_voice_models() is not None @@ -92,6 +101,7 @@ app.include_router(auth.router) app.include_router(sessions.router) app.include_router(scenarios_api.router) app.include_router(ekp_api.router) +app.include_router(groups_api.router) app.include_router(admin_api.router) app.include_router(trainees.router) app.include_router(call_ws.router) @@ -110,4 +120,5 @@ async def health() -> dict: "scenarios_loaded": getattr(app.state, "scenarios_loaded", 0), "embeddings_ready": getattr(app.state, "embeddings_ready", False), "offline": settings.offline, + "demo_no_db": settings.demo_no_db, } diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index 99abffa..71cea80 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome from app.domain.events import Mood +from app.scoring.taxonomy import METRIC_MAP class Strict(BaseModel): @@ -157,6 +158,9 @@ class Scenario(Strict): facts: list[Fact] = [] checklist: list[ChecklistItem] = [] required_fields: list[str] = Field(default_factory=list) + # Учебные веса задаёт преподаватель в утверждаемом сценарии. Ноль + # отключает метрику из знаменателя; значения методики предварительные. + score_weights: dict[str, float] = Field(default_factory=dict) # Реплики оператора, которые вопросом не являются: «успокойтесь», # «оставайтесь на линии». Общий список — checklists/common.yaml, # сценарий может дополнить своими. @@ -165,6 +169,15 @@ class Scenario(Strict): era_glonass: EraGlonass | None = None tree: Tree = Tree() + @model_validator(mode="after") + def valid_score_weights(self): + unknown = self.score_weights.keys() - METRIC_MAP.keys() + if unknown: + raise ValueError(f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}") + if any(not 0 <= weight <= 10 for weight in self.score_weights.values()): + raise ValueError("score_weights: каждый вес должен быть от 0 до 10") + return self + @model_validator(mode="after") def ticket_needs_position(self): if (self.ticket is None) != (self.position is None): diff --git a/backend/app/scoring/group.py b/backend/app/scoring/group.py new file mode 100644 index 0000000..e4f6d62 --- /dev/null +++ b/backend/app/scoring/group.py @@ -0,0 +1,69 @@ +"""Воспроизводимая аналитика учебной группы по сохранённым оценкам. + +Рекомендации здесь основаны на кодах ошибок, а не выдаются за вывод ИИ. +Повторные попытки учитываются в среднем балле, но один курсант не может +увеличить долю группы повторением одной и той же ошибки. +""" + +from collections import Counter, defaultdict +from dataclasses import dataclass +from uuid import UUID + +from app.domain.taxonomy import ERRORS, ErrorCode + + +RECOMMENDATIONS: dict[str, str] = { + "E1": "Повторить опросную карту и обязательные уточняющие вопросы.", + "E2": "Отработать классификацию происшествия и маршрутизацию по ЕКП.", + "E3": "Провести тренировку по временным нормативам.", + "E4": "Разобрать управление разговором и коммуникацию в стрессе.", + "E5": "Потренировать полноту заполнения карточки 112.", + "E6": "Повторить проверку карточки перед завершением и передачей.", + "D1": "Отработать первичную отметку ДДС в течение 30 секунд.", + "D2": "Сверять статусы реагирования с фактическими докладами.", + "D3": "Разобрать профильность обращений и допустимость отказа.", + "D4": "Тренировать обоснование отказа в комментарии.", + "D5": "Тренировать полноту комментария: действие и передача информации.", + "D6": "Повторить цепочку статусов выезда, прибытия и работ.", +} + + +@dataclass(frozen=True) +class ScoredAttempt: + trainee_id: UUID | None + score: float + codes: dict[str, int] + + +def summarize(attempts: list[ScoredAttempt], enrolled: int) -> dict: + """Сводка только по завершённым попыткам с оценкой.""" + active = {item.trainee_id for item in attempts if item.trainee_id is not None} + affected: dict[str, set[UUID]] = defaultdict(set) + occurrences: Counter[str] = Counter() + for attempt in attempts: + for code, count in attempt.codes.items(): + if code not in RECOMMENDATIONS or not isinstance(count, int) or count <= 0: + continue + occurrences[code] += count + if attempt.trainee_id is not None: + affected[code].add(attempt.trainee_id) + + errors = [] + for code in occurrences: + errors.append({ + "code": code, + "title": ERRORS[ErrorCode(code)].title, + "affected_trainees": len(affected[code]), + "occurrences": occurrences[code], + "rate_percent": round(100 * len(affected[code]) / len(active), 1) if active else 0.0, + "recommendation": RECOMMENDATIONS[code], + }) + errors.sort(key=lambda item: (-item["affected_trainees"], -item["occurrences"], item["code"])) + return { + "enrolled_trainees": enrolled, + "active_trainees": len(active), + "scored_attempts": len(attempts), + "average_score": round(sum(item.score for item in attempts) / len(attempts), 1) + if attempts else None, + "errors": errors, + } diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index d1248c2..a1af75b 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -9,6 +9,7 @@ from uuid import UUID from app.domain.events import ( CompetencyScore, + DdsCardReport, HintShown, HintUsage, InstructorNoteShown, @@ -68,23 +69,27 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: step.checklist_id for step in reference.steps if step.required and revealed is not None and step.fact_id not in revealed - ] if state.exercise is not Exercise.CARD else [] + ] if state.exercise is Exercise.CALL else [] return SessionReport( session_id=session_id, - scenario_id=scenario.id, + scenario_id=(state.dds_scenarios[0].id + if state.exercise is Exercise.DDS and state.dds_scenarios + else 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", [])], + card_results=[DdsCardReport.model_validate(item) + for item in score.get("card_results", [])], 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 - ] if state.exercise is not Exercise.CARD else [], + ] if state.exercise is Exercise.CALL else [], missed_checklist=missed_checklist, hints_used=[ HintUsage(checklist_id=checklist_id, question=questions.get(checklist_id, ""), at=at) diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index 0f6a5eb..85d63dc 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -28,6 +28,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "dds_contact": (ErrorCode.D6, Competency.COMMUNICATION), "dds_progress": (ErrorCode.D6, Competency.CARD), "dds_completion": (ErrorCode.D6, Competency.CARD), + "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), } #: Вес метрики в детерминированной оценке. diff --git a/backend/app/scoring/weights.py b/backend/app/scoring/weights.py new file mode 100644 index 0000000..680bee7 --- /dev/null +++ b/backend/app/scoring/weights.py @@ -0,0 +1,10 @@ +"""Пересчёт предварительной шкалы под утверждённый методистом сценарий.""" + +from app.scoring.gost import GostResult + + +def apply_weights(result: GostResult, overrides: dict[str, float]) -> None: + """Меняет только веса метрик; факты, нормативы и ошибки неизменны.""" + for metric in result.metrics: + if metric.key in overrides: + metric.weight = overrides[metric.key] diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py new file mode 100644 index 0000000..38fd6eb --- /dev/null +++ b/backend/app/session/dds.py @@ -0,0 +1,50 @@ +"""Выдача очередной учебной карточки ДДС в рамках одного занятия.""" + +import re + +from app.domain.kio import KIO, ResponseStatus +from app.scenarios.schema import Scenario +from app.session.state import SessionState, now_utc +from app.session.timers import SessionTimers + + +def prepare_card(state: SessionState, scenario: Scenario) -> None: + """Сменить активную карточку, сохранив общий ID занятия и прошлые результаты.""" + state.scenario = scenario.model_copy(deep=True) + state.scenario_id = scenario.id + state.scenario_title = scenario.title + state.level = scenario.level.value + state.required_fields = list(scenario.required_fields) + truth = scenario.ground_truth + address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "") + floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE) + service = truth.dds.value if truth.dds else None + fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"} + state.kio = KIO( + registered_at=now_utc(), caller_number="+7 (495) 000-00-00", + address=truth.address or address_fact or None, + floor=floor.group(1) if floor else None, + incident_type=truth.incident_type, incident_code=truth.incident_code, + dds=truth.dds, signs=list(scenario.signs), + notify=list(truth.notify) or ([fallback[service]] if service in fallback else []), + victims_count=truth.victims, + description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line, + ) + state.dispatched_card = None + if service: + state.dispatch(service) + else: + state.kio.response_status = ResponseStatus.TRANSFERRED + state.dispatched_card = state.kio.model_copy(deep=True) + state.dispatched_at = now_utc() + + state.status_log.clear() + state.dds_log.clear() + state.bounced_fields.clear() + state.crew_selected = None + state.crew_assignments.clear() + state.phone_reports.clear() + state.reply_text = "" + state.reply_log.clear() + state.timers = SessionTimers() + state.on_event("dds.dispatch") diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 997677e..4188c5f 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -10,18 +10,82 @@ import logging from uuid import UUID -from app.domain.events import Exercise, ScoreReady +from app.domain.events import Exercise, Metric, ScoreReady +from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource from app.domain.timers import NORMATIVES, TimerCode from app.scenarios import store from app.scoring.competency import radar from app.scoring.card import evaluate_card from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher from app.scoring.gost import GostResult, evaluate +from app.scoring.weights import apply_weights from app.session.hub import hub +from app.session.state import DdsCardRecord, now_utc log = logging.getLogger(__name__) +def score_current_dds(state) -> DdsCardRecord: + """Оценить активную карточку отдельно, до выдачи следующей.""" + number = state.dds_card_index + 1 + findings = evaluate_dispatcher( + entries=state.status_log, + services=state.notified_services(), + deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms, + elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), + ) + metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms) + reply = state.reply_text.strip() + if reply or len(state.dds_scenarios) > 1: + passed = len(reply) >= 20 and len(reply.split()) >= 3 + metrics.append(Metric( + key="dds_reply", title="Свободный ответ по карточке", + fact=reply or "ответ отсутствует", + norm="содержательный текст не короче 20 символов и 3 слов", + ref="ТЗ: действия с карточками, ввод текста", + passed=passed, weight=1.5, + )) + if not passed: + findings.append(Finding( + code=ErrorCode.D5, source=FindingSource.DISPATCHER, + summary="Свободный ответ отсутствует или слишком краток", + fact=reply or "ответ отсутствует", + norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась", + ref="ТЗ: действия с карточками, ввод текста", + competency=Competency.COMMUNICATION, + )) + weighted = GostResult(metrics=metrics, findings=findings) + apply_weights(weighted, state.scenario.score_weights) + actions = [ + {"type": "card.status", "service": mark.service, "status": mark.status.value, + "comment": mark.comment, "at": mark.at.isoformat()} + for mark in state.status_log + ] + [ + {"type": "phone.report", "service": report.service, "crew": report.crew, + "phase": report.phase, "text": report.text, "at": report.at.isoformat()} + for report in state.phone_reports + ] + [ + {"type": "card.reply", "text": text, "at": at.isoformat()} + for at, text in state.reply_log + ] + [ + {"type": kind, "text": text or "", "at": at.isoformat()} + for kind, at, text in state.dds_log + ] + actions.sort(key=lambda item: item["at"]) + return DdsCardRecord( + card_id=state.dispatched_card.card_id, + scenario_id=state.scenario_id, + reply_text=state.reply_text, + metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"}) + for metric in weighted.metrics], + findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"}) + for finding in weighted.findings], + actions=actions, + duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000)) + if state.dispatched_at else 0), + ) + + async def finish(session_id: UUID, state) -> None: # Сценарий занятия, а не библиотечный: директивы могли поправить эталон. scenario = state.scenario or store.get(state.scenario_id) @@ -31,9 +95,22 @@ async def finish(session_id: UUID, state) -> None: if state.exercise is Exercise.CARD: result = evaluate_card(scenario, state.dispatched_card or state.kio) elif state.exercise is Exercise.DDS: - # В готовой карточке не было входящего звонка, опроса и действий - # оператора 112. Ни одна E-метрика здесь не применима. + # В ДДС могут последовательно пройти несколько карточек. Уже закрытые + # сохранены отдельными результатами; активную оцениваем лишь однажды. result = GostResult() + cards = list(state.dds_completed) + if state.dispatched_card and not any( + item.card_id == state.dispatched_card.card_id for item in cards + ): + cards.append(score_current_dds(state)) + state.dds_completed = cards + for card in cards: + result.metrics.extend(card.metrics) + result.findings.extend(card.findings) + result.unavailable.append( + "Грамматика свободного ответа: автоматическая проверка не настроена; " + "оценивались только наличие и минимальная полнота текста" + ) else: result = evaluate( scenario=scenario, @@ -49,7 +126,7 @@ async def finish(session_id: UUID, state) -> None: # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # участвуют оба (docs/spec/DATASET.md#статусы-реагирования). - if state.dispatched_card is not None and state.exercise is not Exercise.CARD: + if state.dispatched_card is not None and state.exercise is Exercise.CALL: dispatcher_findings = evaluate_dispatcher( entries=state.status_log, services=state.notified_services(), @@ -58,6 +135,8 @@ async def finish(session_id: UUID, state) -> None: ) result.findings.extend(dispatcher_findings) result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)) + if state.exercise is not Exercise.DDS: + apply_weights(result, scenario.score_weights) # Сводка числами: по ней считается дельта между попытками в профиле. # Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам. @@ -82,6 +161,14 @@ async def finish(session_id: UUID, state) -> None: "unavailable": result.unavailable, # Статус карточки — готовая красная метка, понятная любому диспетчеру. "card_status": state.station_snapshot().card.value, + "card_results": [ + {"card_id": str(card.card_id), "scenario_id": card.scenario_id, + "score_auto": card.score_auto, "reply_text": card.reply_text, + "actions": card.actions, "duration_ms": card.duration_ms, + "metrics": [metric.model_dump(mode="json") for metric in card.metrics], + "findings": [finding.model_dump(mode="json") for finding in card.findings]} + for card in cards + ] if state.exercise is Exercise.DDS else [], } log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings)) diff --git a/backend/app/session/journal.py b/backend/app/session/journal.py index cb24f93..fca473c 100644 --- a/backend/app/session/journal.py +++ b/backend/app/session/journal.py @@ -93,6 +93,24 @@ class DbJournal: await self._write(lambda db: action(db)) + async def score_override( + self, session_id: UUID, score_final: float, author: str, comment: str + ) -> None: + """Сохранить решение преподавателя рядом с неизменной автооценкой.""" + async def action(db): + await db.execute( + update(Score) + .where(Score.session_id == session_id) + .values( + score_final=score_final, + overridden_by=author, + override_comment=comment, + ) + ) + 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 0e47cea..f658aa4 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -16,6 +16,7 @@ from app.domain.events import ( Mood, SessionMode, SessionSnapshot, + Metric, Speaker, TranscriptEntry, ) @@ -29,6 +30,7 @@ from app.domain.statuses import ( StationSnapshot, StatusEntry, PhoneReportRecord, + DdsCardSummary, card_status, check, current, @@ -36,6 +38,7 @@ from app.domain.statuses import ( from app.domain.timers import NORMATIVES, TimerCode from app.domain.kio import KIO, ResponseStatus, apply_patch from app.session.timers import SessionTimers +from app.domain.taxonomy import Finding def now_utc() -> datetime: @@ -43,6 +46,23 @@ def now_utc() -> datetime: return datetime.now(timezone.utc) +@dataclass +class DdsCardRecord: + card_id: UUID + scenario_id: str + reply_text: str + metrics: list[Metric] + findings: list[Finding] + actions: list[dict[str, Any]] + duration_ms: int + + @property + def score_auto(self) -> float: + total = sum(metric.weight for metric in self.metrics) + passed = sum(metric.weight for metric in self.metrics if metric.passed) + return round(100 * passed / total, 1) if total else 0.0 + + @dataclass class SessionState: session_id: UUID @@ -102,6 +122,11 @@ class SessionState: crew_selected: str | None = None crew_assignments: dict[str, str] = field(default_factory=dict) phone_reports: list[PhoneReportRecord] = field(default_factory=list) + dds_scenarios: list[Scenario] = field(default_factory=list) + dds_card_index: int = 0 + dds_completed: list[DdsCardRecord] = field(default_factory=list) + reply_text: str = "" + reply_log: list[tuple[datetime, str]] = field(default_factory=list) #: Чем курсант закрыл вызов, если не карточкой (lct-36). resolved_outcome: str | None = None resolve_comment: str = "" @@ -180,6 +205,14 @@ class SessionState: crew_options=self.crew_options(), crew_selected=self.crew_selected, phone_reports=list(self.phone_reports), + card_id=self.dispatched_card.card_id if self.dispatched_card else None, + card_index=self.dds_card_index + 1, + card_total=len(self.dds_scenarios) or 1, + reply_text=self.reply_text, + completed_cards=[DdsCardSummary(card_id=item.card_id, + scenario_id=item.scenario_id, + score_auto=item.score_auto) + for item in self.dds_completed], ) def card_received_event(self): @@ -191,6 +224,8 @@ class SessionState: from_operator=("учебный сценарий" if self.exercise is Exercise.DDS else self.trainee_name or "оператор 112"), at=self.dispatched_at or now_utc(), + card_index=self.dds_card_index + 1, + card_total=len(self.dds_scenarios) or 1, ) @property diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py new file mode 100644 index 0000000..4a079cf --- /dev/null +++ b/backend/tests/test_auth_hardening.py @@ -0,0 +1,167 @@ +"""Regressions for stale cookies and privileged admin operations.""" + +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +import app.api.auth as auth +from app.api.http import admin +from app.domain.roles import Role +from app.main import app +from app.session.hub import hub + + +@pytest.fixture +def client(): + with TestClient(app) as test_client: + hub.journal = None + yield test_client + + +def test_account_change_revokes_http_and_new_websocket_handshakes(client): + assert client.post("/api/auth/dev-token").status_code == 200 + assert client.get("/api/auth/me").status_code == 200 + auth.invalidate_login("dev") + assert client.get("/api/auth/me").status_code == 401 + with client.websocket_connect(f"/ws/control/{uuid4()}") as socket: + assert socket.receive_json()["code"] == "forbidden" + assert client.post("/api/auth/dev-token").status_code == 200 + assert client.get("/api/auth/me").status_code == 200 + + +def test_account_change_closes_an_existing_websocket(client): + assert client.post("/api/auth/dev-token").status_code == 200 + with client.websocket_connect(f"/ws/control/{uuid4()}") as socket: + auth.invalidate_login("dev") + with pytest.raises(WebSocketDisconnect) as exc: + socket.receive_json() + assert exc.value.code == 1008 + + +def test_cookie_from_previous_process_is_rejected(client, monkeypatch): + assert client.post("/api/auth/dev-token").status_code == 200 + monkeypatch.setattr(auth, "_INSTANCE", "new-server-instance") + assert client.get("/api/auth/me").status_code == 401 + + +class FakeDb: + def __init__(self, user): + self.user = user + self.commits = 0 + self.added = [] + + async def get(self, _model, _id): + return self.user + + def add(self, object_): + self.added.append(object_) + + async def flush(self): + for object_ in self.added: + if getattr(object_, "id", None) is None: + object_.id = uuid4() + + async def commit(self): + self.commits += 1 + + +def fake_user(login="victim", role="instructor"): + return SimpleNamespace( + id=uuid4(), login=login, full_name="Проверка", role=role, + service=None, trainee_id=None, blocked=False, + password_hash="old", created_at=datetime.now(timezone.utc), + ) + + +@pytest.mark.asyncio +async def test_admin_patch_revokes_cookie_after_commit(monkeypatch): + user = fake_user() + db = FakeDb(user) + calls = [] + monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal( + login="admin", full_name="Администратор", role=Role.ADMIN, + )) + monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits))) + + async def no_audit(*_args, **_kwargs): + return None + + monkeypatch.setattr(admin, "audit", no_audit) + await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db) + assert user.blocked is True + assert calls == [("victim", 1)] + + +@pytest.mark.asyncio +async def test_admin_cannot_demote_self(monkeypatch): + user = fake_user(login="admin", role="admin") + db = FakeDb(user) + monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal( + login="admin", full_name="Администратор", role=Role.ADMIN, + )) + with pytest.raises(HTTPException) as exc: + await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db) + assert exc.value.status_code == 409 + assert db.commits == 0 + + +@pytest.mark.asyncio +async def test_promotion_to_trainee_creates_profile(monkeypatch): + user = fake_user() + db = FakeDb(user) + monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal( + login="admin", full_name="Администратор", role=Role.ADMIN, + )) + + async def no_audit(*_args, **_kwargs): + return None + + monkeypatch.setattr(admin, "audit", no_audit) + await admin.patch_user(user.id, admin.UserPatch(role=Role.TRAINEE), object(), db) + assert user.role == "trainee" + assert user.trainee_id is not None + assert db.commits == 1 + + +@pytest.mark.asyncio +async def test_backup_runs_off_event_loop_and_failure_is_audited(monkeypatch): + who = auth.Principal(login="admin", full_name="Администратор", role=Role.ADMIN) + monkeypatch.setattr(admin, "require", lambda _request, *_roles: who) + calls = [] + + async def fake_threadpool(fn): + calls.append(fn) + return fn() + + async def fake_audit(*args, **kwargs): + calls.append((args, kwargs)) + + monkeypatch.setattr(admin, "run_in_threadpool", fake_threadpool) + monkeypatch.setattr(admin, "audit", fake_audit) + monkeypatch.setattr(admin.backup_service, "create", lambda: { + "name": "example.sql", "size_bytes": 1, "at": datetime.now(timezone.utc), + }) + assert (await admin.make_backup(object())).name == "example.sql" + assert calls[0] is admin.backup_service.create + + def broken(): + raise admin.backup_service.BackupError("pg_dump failed") + + monkeypatch.setattr(admin.backup_service, "create", broken) + with pytest.raises(HTTPException) as exc: + await admin.make_backup(object()) + assert exc.value.status_code == 503 + assert any(isinstance(item, tuple) and item[0][2] == "backup.failed" for item in calls) + + +def test_backup_error_redacts_database_credentials(monkeypatch): + dsn = "postgresql://user:supersecret@localhost:5432/example" + monkeypatch.setattr(admin, "get_settings", lambda: SimpleNamespace(database_url=dsn)) + detail = admin._safe_backup_error(admin.backup_service.BackupError(f"bad DSN: {dsn}")) + assert "supersecret" not in detail + assert dsn not in detail diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py index e5e7170..c8fab6f 100644 --- a/backend/tests/test_dds_exercise.py +++ b/backend/tests/test_dds_exercise.py @@ -139,3 +139,124 @@ def test_complete_dds_workflow_scores_without_call_penalties(client): assert all(metric["key"].startswith("dds_") for metric in score["metrics"]) finally: control.__exit__(None, None, None) + + +def test_sequential_dds_cards_keep_separate_state_and_scores(client): + session_id = uuid4() + control_ctx = client.websocket_connect(f"/ws/control/{session_id}") + control = control_ctx.__enter__() + control.send_json({ + "type": "scenario.start", "scenario_id": "fire-apartment-l2", + "scenario_ids": ["fire-apartment-l2", "t20-2-stroke"], + "trainee": "Иванов", "mode": "training", "exercise": "dds", + }) + wait_for(lambda: hub.get(session_id)) + try: + with client.websocket_connect(f"/ws/station/{session_id}") as station: + first = read_until(station, "card.received") + first_card_id = first["card"]["card_id"] + assert (first["card_index"], first["card_total"]) == (1, 2) + snapshot = read_until(station, "station.state")["snapshot"] + service = snapshot["services"][0] + station.send_json({"type": "card.status", "service": service, "status": "accepted"}) + read_until(station, "station.state") + station.send_json({"type": "card.reply", "card_id": first_card_id, + "text": "Сообщение принято, дежурная бригада направлена на место."}) + assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение") + station.send_json({"type": "card.next", "card_id": first_card_id}) + second = read_until(station, "card.received") + second_card_id = second["card"]["card_id"] + assert second_card_id != first_card_id + assert second["card"]["incident_type"] == "medical" + assert (second["card_index"], second["card_total"]) == (2, 2) + snapshot = read_until(station, "station.state")["snapshot"] + assert snapshot["reply_text"] == "" + assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"] + assert len(snapshot["completed_cards"]) == 1 + assert snapshot["completed_cards"][0]["card_id"] == first_card_id + station.send_json({"type": "card.reply", "card_id": first_card_id, + "text": "Запоздалый ответ к прошлой карточке"}) + assert "не к текущей" in read_until(station, "error")["message"] + assert hub.get(session_id).reply_text == "" + station.send_json({"type": "card.next", "card_id": first_card_id}) + assert "ID" in read_until(station, "error")["message"] + assert hub.get(session_id).dds_card_index == 1 + + with client.websocket_connect(f"/ws/station/{session_id}") as station: + assert read_until(station, "card.received")["card"]["card_id"] == second_card_id + assert read_until(station, "station.state")["snapshot"]["card_index"] == 2 + station.send_json({"type": "card.reply", "card_id": second_card_id, + "text": "Сообщение принято, бригада направлена на место происшествия."}) + read_until(station, "station.state") + station.send_json({"type": "card.next", "card_id": second_card_id}) + read_until(station, "score.ready") + + state = hub.get(session_id) + assert state.ended and len(state.dds_completed) == 2 + assert len(state.score["card_results"]) == 2 + assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" + assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke" + assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"} + assert all(item["code"].startswith("D") for item in state.score["findings"]) + report = client.get(f"/api/sessions/{session_id}/report").json() + assert report["scenario_id"] == "fire-apartment-l2" + assert len(report["card_results"]) == 2 + assert report["missed_checklist"] == [] and report["reference_questions"] == [] + first_actions = report["card_results"][0]["actions"] + assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"} + assert report["card_results"][0]["duration_ms"] >= 0 + finally: + control_ctx.__exit__(None, None, None) + + +def test_instructor_can_end_multi_card_early_without_grading_future_cards(client): + session_id = uuid4() + control_ctx = client.websocket_connect(f"/ws/control/{session_id}") + control = control_ctx.__enter__() + control.send_json({ + "type": "scenario.start", "scenario_id": "fire-apartment-l2", + "scenario_ids": ["fire-apartment-l2", "t20-2-stroke"], + "trainee": "Иванов", "mode": "training", "exercise": "dds", + }) + wait_for(lambda: hub.get(session_id)) + try: + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "card.received") + read_until(station, "station.state") + control.send_json({"type": "session.stop"}) + read_until(station, "score.ready") + state = hub.get(session_id) + assert state.ended + assert len(state.score["card_results"]) == 1 + assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" + finally: + control_ctx.__exit__(None, None, None) + + +def test_each_dds_card_uses_its_own_scenario_weights(): + from pathlib import Path + + from app.domain.events import Exercise, SessionMode + from app.scenarios.loader import load_file + from app.session.dds import prepare_card + from app.session.finish import score_current_dds + from app.session.state import SessionState + + root = Path(__file__).resolve().parents[2] / "scenarios" + base = load_file(root / "fire-apartment-l2.yaml", root) + first = base.model_copy(deep=True) + second = base.model_copy(deep=True) + first.score_weights = {"dds_reply": 7.0} + second.score_weights = {"dds_reply": 2.0} + state = SessionState( + session_id=uuid4(), scenario_id=base.id, scenario_title=base.title, + level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, + dds_scenarios=[first, second], + ) + prepare_card(state, first) + first_record = score_current_dds(state) + state.dds_card_index = 1 + prepare_card(state, second) + second_record = score_current_dds(state) + assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0 + assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0 diff --git a/backend/tests/test_demo_no_db.py b/backend/tests/test_demo_no_db.py new file mode 100644 index 0000000..e213e91 --- /dev/null +++ b/backend/tests/test_demo_no_db.py @@ -0,0 +1,75 @@ +"""Явный локальный демо-режим не зависит от Postgres.""" + +import time +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.api.auth import DEMO_TRAINEE_ID +from app.config import get_settings +from app.main import app +from app.session.hub import hub + + +def _wait_for(predicate, timeout=3): + until = time.monotonic() + timeout + while time.monotonic() < until: + value = predicate() + if value: + return value + time.sleep(0.02) + raise AssertionError("занятие не стартовало") + + +def _read_until(socket, wanted): + for _ in range(20): + event = socket.receive_json() + if event["type"] == wanted: + return event + raise AssertionError(f"не пришло событие {wanted}") + + +def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch): + monkeypatch.setenv("DEMO_NO_DB", "true") + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + monkeypatch.setenv("VOICE_ENABLED", "false") + get_settings.cache_clear() + try: + with TestClient(app) as client: + health = client.get("/api/health").json() + assert health["status"] == "ok" and health["demo_no_db"] is True + assert health["scenarios_loaded"] > 0 + assert hub.journal is None + + assert client.post("/api/auth/login", json={ + "login": "demo-instructor", "password": "demo" + }).json()["role"] == "instructor" + assert client.post("/api/auth/login", json={ + "login": "unknown", "password": "demo" + }).status_code == 401 + assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID) + assert client.post("/api/auth/dev-token").json()["role"] == "instructor" + assert client.get("/api/trainees").json() == [ + {"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None} + ] + # БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout. + assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo" + + 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_id": str(DEMO_TRAINEE_ID), + "mode": "training", "exercise": "dds", + }) + state = _wait_for(lambda: hub.get(session_id)) + assert state.trainee_id == DEMO_TRAINEE_ID + who = client.post("/api/auth/login", json={ + "login": "demo-trainee", "password": "demo" + }).json() + assert who["trainee_id"] == str(DEMO_TRAINEE_ID) + with client.websocket_connect(f"/ws/station/{session_id}") as station: + assert _read_until(station, "card.received")["card"]["address"] + assert _read_until(station, "station.state")["snapshot"]["card_index"] == 1 + finally: + get_settings.cache_clear() diff --git a/backend/tests/test_group_analytics.py b/backend/tests/test_group_analytics.py new file mode 100644 index 0000000..d67f3e6 --- /dev/null +++ b/backend/tests/test_group_analytics.py @@ -0,0 +1,75 @@ +"""Групповая сводка считает людей, а не число их повторных попыток.""" + +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from starlette.requests import Request + +from app.api import auth +from app.api.http import groups as group_api +from app.api.auth import Principal +from app.domain.roles import Role +from app.scoring.group import ScoredAttempt, summarize + + +def test_group_errors_count_distinct_trainees_and_give_actions(): + first, second = uuid4(), uuid4() + result = summarize([ + ScoredAttempt(first, 40.0, {"E1": 2, "D1": 1}), + ScoredAttempt(first, 60.0, {"E1": 1}), + ScoredAttempt(second, 80.0, {"E1": 1}), + ], enrolled=3) + + assert result["active_trainees"] == 2 + assert result["scored_attempts"] == 3 + assert result["average_score"] == 60.0 + assert result["errors"][0]["code"] == "E1" + assert result["errors"][0]["affected_trainees"] == 2 + assert result["errors"][0]["occurrences"] == 4 + assert result["errors"][0]["rate_percent"] == 100.0 + assert result["errors"][0]["recommendation"] + + +def test_group_without_scored_attempts_has_no_fake_recommendations(): + result = summarize([], enrolled=5) + assert result == { + "enrolled_trainees": 5, + "active_trainees": 0, + "scored_attempts": 0, + "average_score": None, + "errors": [], + } + + +def test_unknown_codes_do_not_break_group_summary(): + result = summarize([ScoredAttempt(uuid4(), 75.0, {"unknown": 2, "E5": 1})], enrolled=1) + assert [item["code"] for item in result["errors"]] == ["E5"] + + +@pytest.mark.asyncio +async def test_group_creation_requires_staff_and_returns_new_group(monkeypatch): + class FakeDb: + def add(self, group): + group.id = uuid4() + + async def commit(self): + pass + + async def no_audit(*args): + pass + + monkeypatch.setattr(group_api, "audit", no_audit) + instructor = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR) + request = Request({"type": "http", "session": {}}) + auth._issue_session(request, instructor) + created = await group_api.create(group_api.GroupCreate(name=" Группа 1 "), request, FakeDb()) + assert created.name == "Группа 1" + assert created.id + + trainee = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE) + forbidden = Request({"type": "http", "session": {}}) + auth._issue_session(forbidden, trainee) + with pytest.raises(HTTPException) as exc: + await group_api.create(group_api.GroupCreate(name="Чужая"), forbidden, FakeDb()) + assert exc.value.status_code == 403 diff --git a/backend/tests/test_score_weights.py b/backend/tests/test_score_weights.py new file mode 100644 index 0000000..51235a1 --- /dev/null +++ b/backend/tests/test_score_weights.py @@ -0,0 +1,39 @@ +"""Веса метрик настраиваются сценарием и не меняют факт проверки.""" + +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.domain.events import Metric +from app.scenarios.loader import load_file +from app.scenarios.schema import Scenario +from app.scoring.gost import GostResult +from app.scoring.weights import apply_weights + + +LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" + + +def test_weight_override_changes_score_but_not_findings(): + result = GostResult(metrics=[ + Metric(key="address", title="Адрес", fact="пусто", norm="заполнен", passed=False, weight=2), + Metric(key="required_fields", title="Поля", fact="есть", norm="есть", passed=True, weight=2), + ]) + assert result.score == 50.0 + apply_weights(result, {"address": 6.0}) + assert result.score == 25.0 + assert result.metrics[0].fact == "пусто" + + +def test_scenario_accepts_only_known_finite_weights(): + source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY) + body = source.model_dump(mode="json") + body["score_weights"] = {"address": 3.0, "dds_ack": 0.0} + assert Scenario.model_validate(body).score_weights["address"] == 3.0 + body["score_weights"] = {"typo": 3.0} + with pytest.raises(ValidationError, match="неизвестные метрики"): + Scenario.model_validate(body) + body["score_weights"] = {"address": 11.0} + with pytest.raises(ValidationError, match="от 0 до 10"): + Scenario.model_validate(body) diff --git a/backend/tests/test_ws.py b/backend/tests/test_ws.py index 4121d80..8ad4d32 100644 --- a/backend/tests/test_ws.py +++ b/backend/tests/test_ws.py @@ -366,7 +366,34 @@ def test_instructor_correction_keeps_the_automatic_score(client): assert corrected["score_final"] == 80.0 assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом" - assert corrected["overridden_by"] == "преподаватель" + assert corrected["overridden_by"] == "dev" + + +def test_ws_score_override_rejects_other_session_and_invalid_value(client): + with lesson(client) as (session_id, control): + state = hub.get(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: state.score is not None) + auto = state.score["score_auto"] + control.send_json({ + "type": "score.override", "session_id": str(uuid4()), + "verdict": "90", "comment": "не тот номер", + }) + control.send_json({ + "type": "score.override", "session_id": str(session_id), + "verdict": "nan", "comment": "не число", + }) + time.sleep(0.1) + assert state.score["score_auto"] == auto + assert "score_final" not in state.score + control.send_json({ + "type": "score.override", "session_id": str(session_id), + "verdict": "85", "comment": "ручная проверка", + }) + wait_for(lambda: state.score.get("score_final") == 85) + assert state.score["overridden_by"] == "dev" def test_soft_directive_changes_how_the_caller_sounds(client): diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 207e952..c9c583e 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -5,8 +5,10 @@ import { Instructor } from "@/pages/instructor/Instructor"; import { Dds } from "@/pages/dds/Dds"; import { Admin } from "@/pages/admin/Admin"; import { Profile } from "@/pages/profile/Profile"; +import { Groups } from "@/pages/groups/Groups"; import { Wall } from "@/pages/wall/Wall"; import { RequireAuth } from "@/features/auth/Login"; +import { DEMO_MODE } from "@/features/auth/useAuth"; // Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md). // session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой. @@ -18,11 +20,12 @@ const guarded = (screen: string, element: JSX.Element) => ( ); export const router = createBrowserRouter([ - { path: "/", element: }, + { path: "/", element: }, { path: "/trainee", element: guarded("/trainee", ) }, { path: "/instructor", element: guarded("/instructor", ) }, { path: "/wall", element: guarded("/wall", ) }, { path: "/profile", element: guarded("/profile", ) }, + { path: "/groups", element: guarded("/groups", ) }, { path: "/dds", element: guarded("/dds", ) }, { path: "/admin", element: guarded("/admin", ) }, ]); diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login.tsx index 9c6bd5b..df664e7 100644 --- a/frontend/src/features/auth/Login.tsx +++ b/frontend/src/features/auth/Login.tsx @@ -1,9 +1,32 @@ // Экран входа. Ставится перед всеми остальными: номер занятия перестал быть // пропуском, по ссылке пускает роль, а не знание адреса (lct-23). -import { useState, type ReactNode } from "react"; +import { useState, type MouseEvent, type ReactNode } from "react"; -import { SCREENS, useLogin, useLogout, useMe } from "./useAuth"; +import { DEMO_MODE, SCREENS, useDemoLogin, useLogin, useLogout, useMe } from "./useAuth"; + +function DemoLinks() { + const openScreen = (event: MouseEvent, path: string) => { + event.preventDefault(); + const current = new URLSearchParams(window.location.search); + const session = current.get("session") || window.sessionStorage.getItem("demo_session"); + if (session) window.sessionStorage.setItem("demo_session", session); + const target = new URL(path, window.location.origin); + if (session) target.searchParams.set("session", session); + if (path === "/dds") target.searchParams.set("role", current.get("role") || "dds_01"); + window.location.assign(target.pathname + target.search); + }; + + return ; +} export function RequireAuth({ screen, children }: { screen: string; children: ReactNode }) { const me = useMe(); @@ -14,7 +37,9 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re } if (!me.data) return ; - const allowed = SCREENS[me.data.role] ?? []; + const demoUser = DEMO_MODE && ["dev", "demo-instructor"].includes(me.data.login) + && me.data.role === "instructor"; + const allowed = [...(SCREENS[me.data.role] ?? []), ...(demoUser ? ["/trainee"] : [])]; if (!allowed.includes(screen)) { return (
@@ -27,11 +52,12 @@ export function RequireAuth({ screen, children }: { screen: string; children: Re
); } - return <>{children}; + return <>{children}{demoUser && }; } function Login() { const login = useLogin(); + const demoLogin = useDemoLogin(); const [name, setName] = useState(""); const [password, setPassword] = useState(""); @@ -70,6 +96,17 @@ function Login() {

+ {DEMO_MODE &&
+

Локальный деморежим: вход без учётной записи доступен только при включённом обходе авторизации на backend.

+ + {demoLogin.isError &&

+ {demoLogin.error instanceof Error && demoLogin.error.message === "404" + ? "Демо-вход выключен на сервере: запустите backend с DEV_AUTH_BYPASS=true." + : "Не удалось выполнить демо-вход. Проверьте подключение к локальному backend."} +

} +
} {/* Причину отказа не уточняем: «нет такого логина» — это список действующих учётных записей для того, кто подбирает. */} {login.isError &&

Логин или пароль неверны

} diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 334f8c6..7915de0 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -5,6 +5,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Role } from "@/shared/types/generated"; +// Vite подставляет значение при сборке. В обычном стенде демо-вход и его +// элементы интерфейса отсутствуют, даже если backend поддерживает endpoint. +export const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true"; + export interface Principal { login: string; full_name: string; @@ -47,6 +51,17 @@ export function useLogin() { }); } +export function useDemoLogin() { + const cache = useQueryClient(); + return useMutation({ + mutationFn: () => { + if (!DEMO_MODE) throw new Error("demo_disabled"); + return call("/api/auth/dev-token", { method: "POST" }); + }, + onSuccess: (who) => cache.setQueryData(["me"], who), + }); +} + export function useLogout() { const cache = useQueryClient(); return useMutation({ @@ -58,7 +73,7 @@ export function useLogout() { /** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий * и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */ export const SCREENS: Record = { - admin: ["/admin", "/profile"], - instructor: ["/instructor", "/wall", "/profile", "/dds"], + admin: ["/admin", "/profile", "/groups"], + instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"], trainee: ["/trainee", "/dds", "/profile"], }; diff --git a/frontend/src/features/health/StandStatus.tsx b/frontend/src/features/health/StandStatus.tsx index 8505613..896ae82 100644 --- a/frontend/src/features/health/StandStatus.tsx +++ b/frontend/src/features/health/StandStatus.tsx @@ -11,6 +11,10 @@ export function StandStatus() { } if (!health.data) return null; + if (health.data.demo_no_db) { + return
Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.
; + } + const troubles: string[] = []; if (!health.data.models_ready) troubles.push("голосовой контур выключен: звонок будет без звука"); if (!health.data.embeddings_ready) troubles.push("нет модели эмбеддингов: подсказки идут по порядку чек-листа"); diff --git a/frontend/src/features/instructor/ScenarioEditor.tsx b/frontend/src/features/instructor/ScenarioEditor.tsx index b0e3d1a..bea778d 100644 --- a/frontend/src/features/instructor/ScenarioEditor.tsx +++ b/frontend/src/features/instructor/ScenarioEditor.tsx @@ -167,7 +167,7 @@ export function ScenarioEditor({ {draft && ( <> -

Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения.

+

Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.