Implement DDS exercise, customer UI and local demo

This commit is contained in:
andreysk0304 2026-09-21 19:56:13 +03:00
commit 0526b11f91
46 changed files with 2504 additions and 253 deletions

View file

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

View file

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

View file

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

View file

@ -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}",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] = []

View file

@ -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,
}

View file

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

View file

@ -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,
}

View file

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

View file

@ -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),
}
#: Вес метрики в детерминированной оценке.

View file

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

View file

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

View file

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

View file

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

View file

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