chore: обновить паузу занятия от main
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/session/store.py
This commit is contained in:
commit
0d58878cbe
51 changed files with 4545 additions and 87 deletions
|
|
@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256(
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
_generations: dict[str, int] = {}
|
_generations: dict[str, int] = {}
|
||||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||||
|
# Logins already revoked because their users row disappeared.
|
||||||
|
_vanished: set[str] = set()
|
||||||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||||
_generations_synced_at: float | None = None
|
_generations_synced_at: float | None = None
|
||||||
|
|
@ -121,10 +123,13 @@ async def sync_generations() -> None:
|
||||||
# The local-only dev-token principal is synthetic, never stored in users;
|
# The local-only dev-token principal is synthetic, never stored in users;
|
||||||
# a DB watcher must not revoke its in-memory generation during test/demo
|
# a DB watcher must not revoke its in-memory generation during test/demo
|
||||||
# flows that deliberately exercise account invalidation.
|
# flows that deliberately exercise account invalidation.
|
||||||
|
# The revoked generation stays cached: dropping it would make the next
|
||||||
|
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
||||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||||
for login in _generations.keys() - current.keys() - synthetic:
|
_vanished.intersection_update(_generations.keys() - current.keys())
|
||||||
|
for login in _generations.keys() - current.keys() - synthetic - _vanished:
|
||||||
invalidate_login(login)
|
invalidate_login(login)
|
||||||
_generations.pop(login, None)
|
_vanished.add(login)
|
||||||
global _generations_synced_at
|
global _generations_synced_at
|
||||||
_generations_synced_at = time.monotonic()
|
_generations_synced_at = time.monotonic()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services")
|
||||||
|
async def services() -> dict:
|
||||||
|
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
|
||||||
|
|
||||||
|
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
|
||||||
|
курсанту так же безопасно, как группы происшествий.
|
||||||
|
"""
|
||||||
|
data = ekp.catalog()
|
||||||
|
return {"source": data.source, "services": [item.model_dump() for item in data.services]}
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,26 @@ from app.db import repo
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.db.models import Group, Score, Session, Trainee
|
from app.db.models import Group, Score, Session, Trainee
|
||||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||||
|
from app.domain.taxonomy import (
|
||||||
|
ERRORS,
|
||||||
|
ErrorCode,
|
||||||
|
Finding,
|
||||||
|
FindingDecision,
|
||||||
|
FindingReview,
|
||||||
|
FindingSource,
|
||||||
|
)
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
||||||
from app.domain.timers import TimerCode, TimerState
|
from app.domain.timers import TimerCode, TimerState
|
||||||
from app.scoring.export import to_csv, to_pdf
|
from app.scoring.export import to_csv, to_pdf
|
||||||
from app.scoring.report import build as build_report
|
from app.scoring.report import build as build_report
|
||||||
|
from app.scoring.review import has_finding
|
||||||
|
from app.scoring.taxonomy import METRIC_MAP
|
||||||
from app.session.access import can_access
|
from app.session.access import can_access
|
||||||
from app.session.checkpoint import load_state
|
from app.session.checkpoint import load_state
|
||||||
from app.session.finish import override_score
|
from app.session.finish import change_findings, override_score
|
||||||
|
from app.session.store import FindingAdded, FindingReviewed
|
||||||
|
from app.session.timers import now_utc
|
||||||
from app.session.score import scoring_scenario
|
from app.session.score import scoring_scenario
|
||||||
from app.session.hub import hub
|
from app.session.hub import hub
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
@ -656,6 +668,157 @@ async def override(
|
||||||
return SessionReport.model_validate(report["full_report"])
|
return SessionReport.model_validate(report["full_report"])
|
||||||
|
|
||||||
|
|
||||||
|
def _required_text(value: str) -> str:
|
||||||
|
cleaned = value.strip()
|
||||||
|
if not cleaned:
|
||||||
|
raise ValueError("поле обязательно")
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
class FindingReviewIn(BaseModel):
|
||||||
|
"""Решение по отметке: причина обязательна и остаётся в разборе."""
|
||||||
|
|
||||||
|
decision: FindingDecision
|
||||||
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
_reason = field_validator("reason")(_required_text)
|
||||||
|
|
||||||
|
|
||||||
|
class FindingIn(BaseModel):
|
||||||
|
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
|
||||||
|
|
||||||
|
code: ErrorCode
|
||||||
|
fact: str = Field(min_length=1, max_length=1000)
|
||||||
|
norm: str = Field(min_length=1, max_length=1000)
|
||||||
|
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
|
||||||
|
card: int | None = Field(default=None, ge=1)
|
||||||
|
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
|
||||||
|
client_id: UUID | None = None
|
||||||
|
|
||||||
|
_fact = field_validator("fact")(_required_text)
|
||||||
|
_norm = field_validator("norm")(_required_text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _change_findings(
|
||||||
|
session_id: UUID, who, db: AsyncSession | None, make,
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Общий путь решений по отметкам — тот же, что у правки итога.
|
||||||
|
|
||||||
|
`make(who, score)` строит запись изменения по сохранённой оценке или
|
||||||
|
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
|
||||||
|
как и к правке балла.
|
||||||
|
"""
|
||||||
|
state = hub.get(session_id)
|
||||||
|
if state is not None:
|
||||||
|
_require_access(who, state)
|
||||||
|
if state.score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
scenario = scoring_scenario(state)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||||
|
change = make(who, state.score)
|
||||||
|
async with hub.operation(session_id):
|
||||||
|
if not _repeated(state.score, change):
|
||||||
|
change_findings(state, change)
|
||||||
|
return build_report(session_id, state, scenario)
|
||||||
|
|
||||||
|
if db is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
session = await repo.get_session(db, session_id)
|
||||||
|
if session is None:
|
||||||
|
raise HTTPException(status_code=404, detail="session_not_found")
|
||||||
|
_require_access(who, session)
|
||||||
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
if score is None:
|
||||||
|
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||||
|
if (score.report or {}).get("full_report") is None:
|
||||||
|
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||||
|
current = dict(score.report)
|
||||||
|
change = make(who, current)
|
||||||
|
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||||
|
if not _repeated(current, change):
|
||||||
|
await hub.store.commit_archived(session_id, [change])
|
||||||
|
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
|
||||||
|
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
|
||||||
|
score = await db.scalar(
|
||||||
|
select(Score).where(Score.session_id == session_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
report = SessionReport.model_validate(score.report["full_report"])
|
||||||
|
await db.rollback()
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
|
||||||
|
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
|
||||||
|
async def review_finding(
|
||||||
|
session_id: UUID,
|
||||||
|
index: int,
|
||||||
|
body: FindingReviewIn,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession | None = Depends(optional_session),
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
|
||||||
|
def make(who, score: dict) -> FindingReviewed:
|
||||||
|
if not 0 <= index < len(score.get("findings", [])):
|
||||||
|
raise HTTPException(status_code=404, detail="finding_not_found")
|
||||||
|
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
|
||||||
|
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
|
||||||
|
))
|
||||||
|
|
||||||
|
return await _change_findings(session_id, who, db, make)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{session_id}/findings", response_model=SessionReport)
|
||||||
|
async def add_finding(
|
||||||
|
session_id: UUID,
|
||||||
|
body: FindingIn,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession | None = Depends(optional_session),
|
||||||
|
) -> SessionReport:
|
||||||
|
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
|
||||||
|
who = require(request, Role.INSTRUCTOR)
|
||||||
|
|
||||||
|
def make(who, score: dict) -> FindingAdded:
|
||||||
|
service = None
|
||||||
|
prefix = ""
|
||||||
|
exercise = (score.get("full_report") or {}).get("exercise")
|
||||||
|
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
|
||||||
|
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
|
||||||
|
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
|
||||||
|
if (body.card is not None and body.code.value.startswith("E")
|
||||||
|
and exercise != Exercise.DDS.value):
|
||||||
|
raise HTTPException(status_code=422, detail="call_code_without_card")
|
||||||
|
if body.card is not None:
|
||||||
|
cards = score.get("card_results") or []
|
||||||
|
if body.card > len(cards):
|
||||||
|
raise HTTPException(status_code=422, detail="card_not_found")
|
||||||
|
service = cards[body.card - 1].get("managed_service")
|
||||||
|
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
|
||||||
|
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
|
||||||
|
return FindingAdded(role=who.role.value, finding=Finding(
|
||||||
|
code=body.code,
|
||||||
|
source=FindingSource.INSTRUCTOR,
|
||||||
|
summary=f"{prefix}{ERRORS[body.code].title}",
|
||||||
|
fact=body.fact,
|
||||||
|
norm=body.norm,
|
||||||
|
ref="отметка преподавателя на разборе",
|
||||||
|
competency=competency,
|
||||||
|
at=now_utc(),
|
||||||
|
card=body.card,
|
||||||
|
service=service,
|
||||||
|
author=who.login,
|
||||||
|
client_id=body.client_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
return await _change_findings(session_id, who, db, make)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[SessionOut])
|
@router.get("", response_model=list[SessionOut])
|
||||||
async def listing(
|
async def listing(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
|
||||||
dds_service=identity.service or event.dds_service,
|
dds_service=identity.service or event.dds_service,
|
||||||
attempt=identity.attempt,
|
attempt=identity.attempt,
|
||||||
criteria=event.criteria,
|
criteria=event.criteria,
|
||||||
|
socket_last_seen_at=now_utc(),
|
||||||
)
|
)
|
||||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||||
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from uuid import UUID
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import TypeAdapter, ValidationError
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from app.api.ws.session import pump, run_command, session_socket
|
from app.api.ws.session import close_fenced, pump, run_command, session_socket
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
CallEndReason,
|
CallEndReason,
|
||||||
ErrorEvent,
|
ErrorEvent,
|
||||||
|
|
@ -62,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
|
||||||
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
|
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
|
||||||
if entered is None:
|
if entered is None:
|
||||||
return
|
return
|
||||||
_who, state = entered
|
who, state = entered
|
||||||
|
|
||||||
with hub.station(session_id) as queue:
|
async with hub.trainee_socket(
|
||||||
|
session_id, station=True, trainee=who.role is Role.TRAINEE,
|
||||||
|
) as queue:
|
||||||
|
if hub.is_lease_fenced(session_id):
|
||||||
|
await close_fenced(ws)
|
||||||
|
return
|
||||||
sender = asyncio.create_task(pump(ws, queue))
|
sender = asyncio.create_task(pump(ws, queue))
|
||||||
try:
|
try:
|
||||||
# Карточка, переданная до подключения станции, не теряется:
|
# Карточка, переданная до подключения станции, не теряется:
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,11 @@ class Settings(BaseSettings):
|
||||||
def limit_ms(self, code: TimerCode) -> int:
|
def limit_ms(self, code: TimerCode) -> int:
|
||||||
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
|
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
|
||||||
|
|
||||||
|
# Пороги сигналов реестра живых занятий (пульт преподавателя).
|
||||||
|
signal_backlog_threshold: int = Field(default=4, ge=1)
|
||||||
|
signal_refusals_threshold: int = Field(default=2, ge=1)
|
||||||
|
signal_offline_window_seconds: int = Field(default=45, ge=1)
|
||||||
|
|
||||||
def validate_deployment_security(self) -> None:
|
def validate_deployment_security(self) -> None:
|
||||||
"""Reject known development authentication defaults on a production app."""
|
"""Reject known development authentication defaults on a production app."""
|
||||||
if self.app_env != "production":
|
if self.app_env != "production":
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ from pathlib import Path
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
DATA = Path(__file__).parent / "ekp.json"
|
DATA = Path(__file__).parent / "ekp.json"
|
||||||
|
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
|
||||||
|
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
|
||||||
|
CATALOG = Path(__file__).parent / "services_catalog.json"
|
||||||
|
|
||||||
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
||||||
#: не показывается — книга помечает их прямо в первом признаке.
|
#: не показывается — книга помечает их прямо в первом признаке.
|
||||||
|
|
@ -89,6 +92,37 @@ def reference() -> Reference:
|
||||||
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogService(BaseModel):
|
||||||
|
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
organization: str | None = None
|
||||||
|
#: «ДДС», если это явно указано в названии в каталоге.
|
||||||
|
service_level: str | None = None
|
||||||
|
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
|
||||||
|
source_reference: str
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog(BaseModel):
|
||||||
|
source: str
|
||||||
|
transcription: str
|
||||||
|
license: str
|
||||||
|
services: list[CatalogService]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def catalog() -> Catalog:
|
||||||
|
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
|
||||||
|
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
|
||||||
|
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def catalog_names() -> frozenset[str]:
|
||||||
|
return frozenset(service.name for service in catalog().services)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _by_code() -> dict[str, Incident]:
|
def _by_code() -> dict[str, Incident]:
|
||||||
return {incident.code: incident for incident in reference().incidents}
|
return {incident.code: incident for incident in reference().incidents}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ from app.domain.roles import Role
|
||||||
from app.domain.statuses import ServiceStatus, StationSnapshot
|
from app.domain.statuses import ServiceStatus, StationSnapshot
|
||||||
from app.domain.taxonomy import Finding
|
from app.domain.taxonomy import Finding
|
||||||
from app.domain.timers import TimerSnapshot
|
from app.domain.timers import TimerSnapshot
|
||||||
from app.scoring.taxonomy import METRIC_MAP
|
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
|
||||||
|
|
||||||
|
|
||||||
class SessionMode(StrEnum):
|
class SessionMode(StrEnum):
|
||||||
|
|
@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
|
||||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||||
|
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
|
||||||
|
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
|
||||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||||
require_correct_grammar: bool = True
|
require_correct_grammar: bool = True
|
||||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
|
@ -60,7 +62,7 @@ class LessonCriteria(BaseModel):
|
||||||
@field_validator("score_weights")
|
@field_validator("score_weights")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
|
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
|
||||||
unknown = weights.keys() - METRIC_MAP.keys()
|
unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
|
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
|
||||||
if any(not 0 <= weight <= 10 for weight in weights.values()):
|
if any(not 0 <= weight <= 10 for weight in weights.values()):
|
||||||
|
|
@ -715,6 +717,12 @@ class Metric(BaseModel):
|
||||||
passed: bool
|
passed: bool
|
||||||
weight: float = 1.0
|
weight: float = 1.0
|
||||||
credit: float | None = Field(default=None, ge=0, le=1)
|
credit: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
|
||||||
|
card: int | None = None
|
||||||
|
service: str | None = None
|
||||||
|
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
|
||||||
|
passed_auto: bool | None = None
|
||||||
|
credit_auto: float | None = Field(default=None, ge=0, le=1)
|
||||||
|
|
||||||
|
|
||||||
class CompetencyScore(BaseModel):
|
class CompetencyScore(BaseModel):
|
||||||
|
|
@ -764,6 +772,8 @@ class DdsCardReport(BaseModel):
|
||||||
card_id: UUID
|
card_id: UUID
|
||||||
scenario_id: str
|
scenario_id: str
|
||||||
score_auto: float
|
score_auto: float
|
||||||
|
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
|
||||||
|
score_reviewed: float | None = None
|
||||||
reply_text: str
|
reply_text: str
|
||||||
metrics: list[Metric]
|
metrics: list[Metric]
|
||||||
findings: list[Finding]
|
findings: list[Finding]
|
||||||
|
|
@ -803,6 +813,8 @@ class SessionReport(BaseModel):
|
||||||
self_assessment_diff: SelfAssessmentDiff | None = None
|
self_assessment_diff: SelfAssessmentDiff | None = None
|
||||||
notes: list[InstructorNoteShown] = []
|
notes: list[InstructorNoteShown] = []
|
||||||
score_auto: float
|
score_auto: float
|
||||||
|
#: Балл после решений преподавателя по отметкам; None — решений не было.
|
||||||
|
score_reviewed: float | None = None
|
||||||
score_final: float
|
score_final: float
|
||||||
overridden_by: str | None = None
|
overridden_by: str | None = None
|
||||||
override_comment: str | None = None
|
override_comment: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,13 @@ class KIO(BaseModel):
|
||||||
# ДДС. `notify` считается из признаков и правится только системой:
|
# ДДС. `notify` считается из признаков и правится только системой:
|
||||||
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
|
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
|
||||||
notify: list[str] = Field(default_factory=list, description="Список оповещения")
|
notify: list[str] = Field(default_factory=list, description="Список оповещения")
|
||||||
|
#: Службы, добавленные оператором вручную из каталога. Автоматический список
|
||||||
|
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
|
||||||
|
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
|
||||||
|
#: загрузку сохранённых снимков занятий и сданных карточек.
|
||||||
|
notify_extra: list[str] = Field(
|
||||||
|
default_factory=list, max_length=20, description="Дополнительно оповестить"
|
||||||
|
)
|
||||||
dds: DDSCode | None = Field(
|
dds: DDSCode | None = Field(
|
||||||
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
|
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
|
||||||
)
|
)
|
||||||
|
|
@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
||||||
"okato", "address", "street", "building", "entrance", "floor",
|
"okato", "address", "street", "building", "entrance", "floor",
|
||||||
"intercom_code", "description", "incident_group", "signs",
|
"intercom_code", "description", "incident_group", "signs",
|
||||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||||
"evacuation_needed", "dds",
|
"evacuation_needed", "dds", "notify_extra",
|
||||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||||
"fire.people_inside", "fire.smoke_spread",
|
"fire.people_inside", "fire.smoke_spread",
|
||||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||||
|
|
@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
|
||||||
return KIO.model_validate(data)
|
return KIO.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchRejected(ValueError):
|
||||||
|
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_catalog(names: Any) -> None:
|
||||||
|
"""Только службы каталога: свободный текст в адресатах карточки
|
||||||
|
на АРМ ДДС выглядел бы как настоящая служба."""
|
||||||
|
from app.domain import ekp
|
||||||
|
|
||||||
|
if not isinstance(names, list):
|
||||||
|
return # тип и лимит проверит модель
|
||||||
|
known = ekp.catalog_names()
|
||||||
|
unknown = [name for name in names if name not in known]
|
||||||
|
if unknown:
|
||||||
|
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
|
||||||
|
|
||||||
|
|
||||||
|
def _without_auto_services(card: KIO) -> KIO:
|
||||||
|
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
|
||||||
|
иначе на АРМ ДДС одна служба окажется в двух строках."""
|
||||||
|
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
|
||||||
|
if extra == card.notify_extra:
|
||||||
|
return card
|
||||||
|
return card.model_copy(update={"notify_extra": extra})
|
||||||
|
|
||||||
|
|
||||||
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
||||||
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
|
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
|
||||||
|
|
||||||
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
|
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
|
||||||
|
Недопустимое значение — `PatchRejected` или `ValidationError`.
|
||||||
"""
|
"""
|
||||||
|
if "notify_extra" in fields:
|
||||||
|
_check_catalog(fields["notify_extra"])
|
||||||
data = card.model_dump()
|
data = card.model_dump()
|
||||||
for path, value in fields.items():
|
for path, value in fields.items():
|
||||||
if path in READ_ONLY_FIELDS:
|
if path in READ_ONLY_FIELDS:
|
||||||
|
|
@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
||||||
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
|
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
|
||||||
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
|
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
|
||||||
touched = MODIFIER_FIELDS & set(fields)
|
touched = MODIFIER_FIELDS & set(fields)
|
||||||
return derive_incident(updated) if touched else updated
|
return _without_auto_services(derive_incident(updated) if touched else updated)
|
||||||
|
|
|
||||||
1470
backend/app/domain/services_catalog.json
Normal file
1470
backend/app/domain/services_catalog.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,10 @@
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ErrorCode(StrEnum):
|
class ErrorCode(StrEnum):
|
||||||
|
|
@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FindingDecision(StrEnum):
|
||||||
|
"""Решение преподавателя по отметке на разборе."""
|
||||||
|
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
DISMISSED = "dismissed"
|
||||||
|
|
||||||
|
|
||||||
|
class FindingReview(BaseModel):
|
||||||
|
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
|
||||||
|
|
||||||
|
decision: FindingDecision
|
||||||
|
reason: str
|
||||||
|
author: str
|
||||||
|
at: datetime
|
||||||
|
|
||||||
|
|
||||||
class Finding(BaseModel):
|
class Finding(BaseModel):
|
||||||
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
||||||
|
|
||||||
|
|
@ -164,3 +182,32 @@ class Finding(BaseModel):
|
||||||
competency: Competency | None = None
|
competency: Competency | None = None
|
||||||
transcript_ref: str | None = None
|
transcript_ref: str | None = None
|
||||||
at: datetime | None = None
|
at: datetime | None = None
|
||||||
|
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
|
||||||
|
#: по методике METRIC_MAP в пределах той же карточки и службы.
|
||||||
|
metric_key: str | None = None
|
||||||
|
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
|
||||||
|
card: int | None = None
|
||||||
|
service: str | None = None
|
||||||
|
#: Автор ручной отметки преподавателя (`source=instructor`).
|
||||||
|
author: str | None = None
|
||||||
|
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
|
||||||
|
client_id: UUID | None = None
|
||||||
|
#: Все решения по отметке по порядку; действует последнее. Прежние не
|
||||||
|
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
|
||||||
|
reviews: list[FindingReview] = []
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _single_review(cls, data: Any) -> Any:
|
||||||
|
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
|
||||||
|
if isinstance(data, dict) and "review" in data:
|
||||||
|
data = dict(data)
|
||||||
|
single = data.pop("review")
|
||||||
|
if single and not data.get("reviews"):
|
||||||
|
data["reviews"] = [single]
|
||||||
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def review(self) -> FindingReview | None:
|
||||||
|
"""Действующее решение — последнее."""
|
||||||
|
return self.reviews[-1] if self.reviews else None
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
||||||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||||
summary=f"{title}: {fact}; требуется {norm}",
|
summary=f"{title}: {fact}; требуется {norm}",
|
||||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||||
|
metric_key=key,
|
||||||
))
|
))
|
||||||
|
|
||||||
if truth.incident_code:
|
if truth.incident_code:
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@
|
||||||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
from app.domain.statuses import (
|
from app.domain.statuses import (
|
||||||
COMMENT_REQUIRED,
|
COMMENT_REQUIRED,
|
||||||
PRIMARY,
|
PRIMARY,
|
||||||
REFUSAL_COMMENT_REQUIRED,
|
REFUSAL_COMMENT_REQUIRED,
|
||||||
SERVICE_STATUS_LABELS,
|
SERVICE_STATUS_LABELS,
|
||||||
|
PhoneReportRecord,
|
||||||
ServiceStatus,
|
ServiceStatus,
|
||||||
StatusEntry,
|
StatusEntry,
|
||||||
current,
|
current,
|
||||||
|
|
@ -23,19 +26,113 @@ from app.domain.statuses import (
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
from app.domain.events import Metric
|
from app.domain.events import Metric
|
||||||
from app.domain.timers import GOST_REF, TimerCode
|
from app.domain.timers import GOST_REF, TimerCode
|
||||||
|
from app.scoring.timing import TimeNorm, time_metric
|
||||||
|
|
||||||
|
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
|
||||||
|
|
||||||
#: Статусы, означающие, что реагирование действительно шло.
|
#: Статусы, означающие, что реагирование действительно шло.
|
||||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||||
|
|
||||||
|
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
|
||||||
|
REPORT_STATUS = {
|
||||||
|
"dispatched": ServiceStatus.RESPONDING,
|
||||||
|
"arrived": ServiceStatus.ARRIVED,
|
||||||
|
"working": ServiceStatus.WORKING,
|
||||||
|
"completed": ServiceStatus.COMPLETED,
|
||||||
|
}
|
||||||
|
REPORT_LABELS = {
|
||||||
|
"dispatched": "о выезде",
|
||||||
|
"arrived": "о прибытии",
|
||||||
|
"working": "о начале работ",
|
||||||
|
"completed": "о завершении работ",
|
||||||
|
}
|
||||||
|
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
|
||||||
|
REPORT_REACTION_MS = 45_000
|
||||||
|
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
|
||||||
|
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
|
||||||
|
DDS_REPORT_REACTION = TimeNorm(
|
||||||
|
key="dds_report_reaction",
|
||||||
|
title="реакция на доклад бригады",
|
||||||
|
summary="статус по докладу бригады отмечен с нарушением норматива",
|
||||||
|
action="отметить статус по докладу",
|
||||||
|
unmeasured="не отмечен",
|
||||||
|
incomplete="не отмечен",
|
||||||
|
code=ErrorCode.D6,
|
||||||
|
source=FindingSource.DISPATCHER,
|
||||||
|
ref=MEMO_REF,
|
||||||
|
)
|
||||||
|
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
|
||||||
|
#: не штрафуется ни отметкой, ни метрикой.
|
||||||
|
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||||
|
|
||||||
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
|
|
||||||
|
def report_reactions(
|
||||||
|
marks: list[StatusEntry], reports: list[PhoneReportRecord],
|
||||||
|
) -> list[tuple[PhoneReportRecord, int | None]]:
|
||||||
|
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
|
||||||
|
|
||||||
|
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
|
||||||
|
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
|
||||||
|
"""
|
||||||
|
result: list[tuple[PhoneReportRecord, int | None]] = []
|
||||||
|
for report in reports:
|
||||||
|
status = REPORT_STATUS.get(report.phase)
|
||||||
|
if status is None:
|
||||||
|
continue
|
||||||
|
times = [mark.at for mark in marks if mark.status is status]
|
||||||
|
if any(at <= report.at for at in times):
|
||||||
|
result.append((report, 0))
|
||||||
|
continue
|
||||||
|
after = min(times, default=None)
|
||||||
|
result.append((
|
||||||
|
report,
|
||||||
|
int((after - report.at).total_seconds() * 1000) if after is not None else None,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def report_reaction(
|
||||||
|
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
|
||||||
|
) -> tuple[Metric, Finding | None] | None:
|
||||||
|
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
|
||||||
|
|
||||||
|
Одна функция для отметок и для балла: иначе метрика провалится там,
|
||||||
|
где отметки нет. None — реагировать было не на что.
|
||||||
|
"""
|
||||||
|
refused = current(marks, service) in TERMINAL_REFUSAL
|
||||||
|
reactions = [(report, delay) for report, delay in report_reactions(marks, reports)
|
||||||
|
if delay is not None or not refused]
|
||||||
|
if not reactions:
|
||||||
|
return None
|
||||||
|
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
|
||||||
|
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
|
||||||
|
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
|
||||||
|
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
|
||||||
|
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
|
||||||
|
f"{REPORT_LABELS[report.phase]}: {detail}")
|
||||||
|
if len(reactions) > 1:
|
||||||
|
late = sum(item is None or item > limit_ms for _, item in reactions)
|
||||||
|
fact += f"; с нарушением {late} из {len(reactions)} докладов"
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
|
||||||
|
})
|
||||||
|
if finding is not None:
|
||||||
|
finding = finding.model_copy(update={
|
||||||
|
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
|
||||||
|
})
|
||||||
|
return metric, finding
|
||||||
|
|
||||||
|
|
||||||
|
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||||
|
norm: str | None = None) -> Finding:
|
||||||
return Finding(
|
return Finding(
|
||||||
|
service=service,
|
||||||
code=code,
|
code=code,
|
||||||
source=FindingSource.DISPATCHER,
|
source=FindingSource.DISPATCHER,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm,
|
norm=norm,
|
||||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
ref=MEMO_REF,
|
||||||
competency=(
|
competency=(
|
||||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||||
),
|
),
|
||||||
|
|
@ -91,6 +188,8 @@ def evaluate_dispatcher(
|
||||||
reply_text: str = "",
|
reply_text: str = "",
|
||||||
expected_decision: str = "accept",
|
expected_decision: str = "accept",
|
||||||
expected_decision_reason: str | None = None,
|
expected_decision_reason: str | None = None,
|
||||||
|
phone_reports: list[PhoneReportRecord] | None = None,
|
||||||
|
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||||
) -> list[Finding]:
|
) -> list[Finding]:
|
||||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||||
|
|
||||||
|
|
@ -117,7 +216,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if missing_comment_statuses:
|
if missing_comment_statuses:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: к статусу не добавлены основание и сведения",
|
f"{service}: к статусу не добавлены основание и сведения",
|
||||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||||
|
|
@ -132,7 +231,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if incomplete_comment_statuses:
|
if incomplete_comment_statuses:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||||
|
|
@ -152,7 +251,7 @@ def evaluate_dispatcher(
|
||||||
]
|
]
|
||||||
if missing_recipients:
|
if missing_recipients:
|
||||||
findings.append(_finding(
|
findings.append(_finding(
|
||||||
ErrorCode.D5,
|
service, ErrorCode.D5,
|
||||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||||
fact="; ".join(missing_recipients)[:500],
|
fact="; ".join(missing_recipients)[:500],
|
||||||
norm=(
|
norm=(
|
||||||
|
|
@ -166,7 +265,7 @@ def evaluate_dispatcher(
|
||||||
if primary is None:
|
if primary is None:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D1,
|
service, ErrorCode.D1,
|
||||||
f"{service}: статус реагирования не проставлен",
|
f"{service}: статус реагирования не проставлен",
|
||||||
fact=(
|
fact=(
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
f"прошло {elapsed_ms // 1000} с"
|
||||||
|
|
@ -181,7 +280,7 @@ def evaluate_dispatcher(
|
||||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D1,
|
service, ErrorCode.D1,
|
||||||
f"{service}: первичный статус проставлен с нарушением срока",
|
f"{service}: первичный статус проставлен с нарушением срока",
|
||||||
fact=(
|
fact=(
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
f"прошло {elapsed_ms // 1000} с"
|
||||||
|
|
@ -197,7 +296,7 @@ def evaluate_dispatcher(
|
||||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D4,
|
service, ErrorCode.D4,
|
||||||
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
||||||
fact="причина отказа не указана",
|
fact="причина отказа не указана",
|
||||||
norm="комментарий к отказу обязателен",
|
norm="комментарий к отказу обязателен",
|
||||||
|
|
@ -209,7 +308,7 @@ def evaluate_dispatcher(
|
||||||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D3,
|
service, ErrorCode.D3,
|
||||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||||
fact="служба есть в списке оповещения по ЕКП",
|
fact="служба есть в списке оповещения по ЕКП",
|
||||||
norm="принять согласно эталону сценария",
|
norm="принять согласно эталону сценария",
|
||||||
|
|
@ -218,7 +317,7 @@ def evaluate_dispatcher(
|
||||||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: принято вопреки эталону сценария",
|
f"{service}: принято вопреки эталону сценария",
|
||||||
fact="карточка принята службой",
|
fact="карточка принята службой",
|
||||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||||
|
|
@ -232,7 +331,7 @@ def evaluate_dispatcher(
|
||||||
if service not in crew_assignments:
|
if service not in crew_assignments:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||||
fact="бригада не выбрана, после приёма статусов не было",
|
fact="бригада не выбрана, после приёма статусов не было",
|
||||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||||
|
|
@ -241,7 +340,7 @@ def evaluate_dispatcher(
|
||||||
else:
|
else:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||||
fact="после приёма статусов не было",
|
fact="после приёма статусов не было",
|
||||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||||
|
|
@ -252,7 +351,7 @@ def evaluate_dispatcher(
|
||||||
) and service not in crew_assignments:
|
) and service not in crew_assignments:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D2,
|
service, ErrorCode.D2,
|
||||||
f"{service}: ход реагирования без назначения бригады",
|
f"{service}: ход реагирования без назначения бригады",
|
||||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||||
|
|
@ -262,15 +361,18 @@ def evaluate_dispatcher(
|
||||||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||||
|
reports = [report for report in phone_reports or () if report.service == service]
|
||||||
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
||||||
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||||
|
reported_done = any(report.phase == "completed" for report in reports)
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D6,
|
service, ErrorCode.D6,
|
||||||
f"{service}: ход реагирования не доведён до конца",
|
f"{service}: ход реагирования не доведён до конца",
|
||||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
|
||||||
|
+ ("; бригада доложила о завершении работ" if reported_done else ""),
|
||||||
norm=(
|
norm=(
|
||||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||||
|
|
@ -278,6 +380,11 @@ def evaluate_dispatcher(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
|
||||||
|
reaction = report_reaction(service, marks, reports, report_reaction_ms)
|
||||||
|
if reaction is not None and reaction[1] is not None:
|
||||||
|
findings.append(reaction[1])
|
||||||
|
|
||||||
return findings
|
return findings
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -288,6 +395,7 @@ def dispatcher_metrics(
|
||||||
expected_decision_reason: str | None = None,
|
expected_decision_reason: str | None = None,
|
||||||
*,
|
*,
|
||||||
services: list[str] | None = None,
|
services: list[str] | None = None,
|
||||||
|
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||||
) -> list[Metric]:
|
) -> list[Metric]:
|
||||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||||
|
|
||||||
|
|
@ -304,7 +412,7 @@ def dispatcher_metrics(
|
||||||
|
|
||||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||||
metrics.append(Metric(
|
metrics.append(Metric(
|
||||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
|
||||||
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||||
passed=passed, weight=weight,
|
passed=passed, weight=weight,
|
||||||
))
|
))
|
||||||
|
|
@ -318,6 +426,12 @@ def dispatcher_metrics(
|
||||||
f"≤ {deadline_ms // 1000} с")
|
f"≤ {deadline_ms // 1000} с")
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
continue
|
||||||
|
# Метрика есть только там, где бригада докладывала: без докладов
|
||||||
|
# реагировать не на что, и отсутствие звонков не штраф.
|
||||||
|
reports = [report for report in card.phone_reports if report.service == service]
|
||||||
|
reaction = report_reaction(service, marks, reports, report_reaction_ms)
|
||||||
|
if reaction is not None:
|
||||||
|
metrics.append(reaction[0])
|
||||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||||
else ServiceStatus.DECLINED)
|
else ServiceStatus.DECLINED)
|
||||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from html import escape
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.domain.events import SessionReport
|
from app.domain.events import SessionReport
|
||||||
|
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
|
||||||
|
|
||||||
|
|
||||||
def _cell(value: object) -> str:
|
def _cell(value: object) -> str:
|
||||||
|
|
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
result = str(value)
|
result = str(value)
|
||||||
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
|
||||||
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||||
return "'" + result
|
return "'" + result
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _finding_origin(item: Finding) -> str:
|
||||||
|
if item.source is FindingSource.INSTRUCTOR:
|
||||||
|
return f"; добавлена преподавателем {item.author or ''}".rstrip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _finding_decision(item: Finding) -> str:
|
||||||
|
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
|
||||||
|
return "".join(
|
||||||
|
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
|
||||||
|
f"преподавателем {review.author}: {review.reason}"
|
||||||
|
for review in item.reviews
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def to_csv(report: SessionReport) -> bytes:
|
def to_csv(report: SessionReport) -> bytes:
|
||||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||||
output = io.StringIO(newline="")
|
output = io.StringIO(newline="")
|
||||||
|
|
@ -38,12 +54,15 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Занятие", "", "Режим", report.mode.value)
|
row("Занятие", "", "Режим", report.mode.value)
|
||||||
row("Занятие", "", "Попытка", report.attempt)
|
row("Занятие", "", "Попытка", report.attempt)
|
||||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||||
|
row("Критерии", "", "Лимит реакции на доклад бригады, с",
|
||||||
|
report.criteria.dds_report_reaction_limit_seconds)
|
||||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||||
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
||||||
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
||||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||||
|
row("Оценка", "", "После разбора отметок", report.score_reviewed)
|
||||||
row("Оценка", "", "Итоговая", report.score_final)
|
row("Оценка", "", "Итоговая", report.score_final)
|
||||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||||
|
|
@ -54,7 +73,9 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||||
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
|
||||||
for number, item in enumerate(report.findings, 1):
|
for number, item in enumerate(report.findings, 1):
|
||||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
row("Ошибки", number, item.code.value, item.summary,
|
||||||
|
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
|
||||||
|
f"{_finding_origin(item)}{_finding_decision(item)}")
|
||||||
for number, item in enumerate(report.competencies, 1):
|
for number, item in enumerate(report.competencies, 1):
|
||||||
row("Компетенции", number, item.competency, item.value)
|
row("Компетенции", number, item.competency, item.value)
|
||||||
for number, item in enumerate(report.transcript, 1):
|
for number, item in enumerate(report.transcript, 1):
|
||||||
|
|
@ -77,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Сверка самооценки", "", label, ", ".join(values))
|
row("Сверка самооценки", "", label, ", ".join(values))
|
||||||
for number, item in enumerate(report.notes, 1):
|
for number, item in enumerate(report.notes, 1):
|
||||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _font_paths() -> tuple[Path, Path | None]:
|
def _font_paths() -> tuple[Path, Path | None]:
|
||||||
|
|
@ -151,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
("Режим", report.mode.value),
|
("Режим", report.mode.value),
|
||||||
("Попытка", report.attempt),
|
("Попытка", report.attempt),
|
||||||
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||||
|
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
|
||||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||||
|
|
@ -160,6 +182,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
("Итоговая оценка", report.score_final),
|
("Итоговая оценка", report.score_final),
|
||||||
):
|
):
|
||||||
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||||
|
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
|
||||||
|
if report.score_reviewed is not None:
|
||||||
|
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
|
||||||
if report.overridden_by:
|
if report.overridden_by:
|
||||||
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||||
if report.override_comment:
|
if report.override_comment:
|
||||||
|
|
@ -183,7 +208,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
story.append(p("Ошибок не выявлено", muted))
|
story.append(p("Ошибок не выявлено", muted))
|
||||||
for item in report.findings:
|
for item in report.findings:
|
||||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
|
||||||
|
f"Источник: {item.ref or 'не указан'}"
|
||||||
|
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
|
||||||
|
|
||||||
section("Компетенции")
|
section("Компетенции")
|
||||||
for item in report.competencies:
|
for item in report.competencies:
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ class _Builder:
|
||||||
norm=norm,
|
norm=norm,
|
||||||
ref=ref,
|
ref=ref,
|
||||||
competency=competency,
|
competency=competency,
|
||||||
|
metric_key=key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -174,6 +175,7 @@ def evaluate(
|
||||||
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
||||||
ref="чек-лист сценария",
|
ref="чек-лист сценария",
|
||||||
competency=Competency.INTERVIEW,
|
competency=Competency.INTERVIEW,
|
||||||
|
metric_key="checklist_completeness",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||||
self_assessment_diff=difference,
|
self_assessment_diff=difference,
|
||||||
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
||||||
score_auto=score.get("score_auto", 0.0),
|
score_auto=score.get("score_auto", 0.0),
|
||||||
|
score_reviewed=score.get("score_reviewed"),
|
||||||
score_final=score.get("score_final", score.get("score_auto", 0.0)),
|
score_final=score.get("score_final", score.get("score_auto", 0.0)),
|
||||||
overridden_by=score.get("overridden_by"),
|
overridden_by=score.get("overridden_by"),
|
||||||
override_comment=score.get("override_comment"),
|
override_comment=score.get("override_comment"),
|
||||||
|
|
|
||||||
193
backend/app/scoring/review.py
Normal file
193
backend/app/scoring/review.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
|
||||||
|
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
|
||||||
|
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
|
||||||
|
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
|
||||||
|
если генератор знает метрику точно, она записана в `Finding.metric_key`.
|
||||||
|
|
||||||
|
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
|
||||||
|
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
|
||||||
|
итога в `apply_score_override`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
|
||||||
|
from app.scoring.competency import radar
|
||||||
|
from app.scoring.gost import GostResult
|
||||||
|
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
|
||||||
|
|
||||||
|
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
|
||||||
|
"reviewed_metrics"]
|
||||||
|
|
||||||
|
|
||||||
|
def _in_scope(finding: Finding, metric: Metric) -> bool:
|
||||||
|
if finding.card != metric.card:
|
||||||
|
return False
|
||||||
|
return finding.service is None or metric.service is None or finding.service == metric.service
|
||||||
|
|
||||||
|
|
||||||
|
def linked(finding: Finding, metric: Metric) -> bool:
|
||||||
|
"""Отметка объясняет провал этой метрики."""
|
||||||
|
if not _in_scope(finding, metric):
|
||||||
|
return False
|
||||||
|
if finding.metric_key is not None:
|
||||||
|
return finding.metric_key == metric.key
|
||||||
|
mapping = METRIC_MAP.get(metric.key)
|
||||||
|
return mapping is not None and mapping[0] is finding.code
|
||||||
|
|
||||||
|
|
||||||
|
def _dismissed(finding: Finding) -> bool:
|
||||||
|
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
|
||||||
|
|
||||||
|
|
||||||
|
def _credit(metric: Metric) -> float:
|
||||||
|
return metric.credit if metric.credit is not None else float(metric.passed)
|
||||||
|
|
||||||
|
|
||||||
|
def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
|
||||||
|
instructor_weight: float = 1.0) -> list[Metric]:
|
||||||
|
"""Метрики с учётом решений преподавателя.
|
||||||
|
|
||||||
|
Проваленная метрика засчитывается, только если все её автоматические
|
||||||
|
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
|
||||||
|
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
|
||||||
|
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
|
||||||
|
"""
|
||||||
|
base = [
|
||||||
|
metric.model_copy(update={
|
||||||
|
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
|
||||||
|
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
|
||||||
|
"passed_auto": None,
|
||||||
|
"credit_auto": None,
|
||||||
|
})
|
||||||
|
for metric in metrics
|
||||||
|
if metric.key != INSTRUCTOR_METRIC
|
||||||
|
]
|
||||||
|
result: list[Metric] = []
|
||||||
|
for metric in base:
|
||||||
|
links = [finding for finding in findings if linked(finding, metric)]
|
||||||
|
manual = [finding for finding in links
|
||||||
|
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
|
||||||
|
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
|
||||||
|
if manual and _credit(metric) > 0:
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"passed": False, "credit": 0.0,
|
||||||
|
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||||
|
})
|
||||||
|
elif (not manual and _credit(metric) < 1 and automatic
|
||||||
|
and all(_dismissed(finding) for finding in automatic)):
|
||||||
|
metric = metric.model_copy(update={
|
||||||
|
"passed": True, "credit": 1.0,
|
||||||
|
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||||
|
})
|
||||||
|
result.append(metric)
|
||||||
|
|
||||||
|
for finding in findings:
|
||||||
|
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
|
||||||
|
and not any(linked(finding, metric) for metric in base)):
|
||||||
|
result.append(Metric(
|
||||||
|
key=INSTRUCTOR_METRIC,
|
||||||
|
title=f"Отметка преподавателя: {finding.summary}",
|
||||||
|
fact=finding.fact,
|
||||||
|
norm=finding.norm or "по решению преподавателя",
|
||||||
|
ref=finding.ref,
|
||||||
|
passed=False,
|
||||||
|
weight=instructor_weight,
|
||||||
|
card=finding.card,
|
||||||
|
service=finding.service,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _instructor_weight(report: dict) -> float:
|
||||||
|
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
|
||||||
|
criteria = (report.get("full_report") or {}).get("criteria") or {}
|
||||||
|
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _codes(findings: list[Finding]) -> dict[str, int]:
|
||||||
|
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
|
||||||
|
codes: dict[str, int] = {}
|
||||||
|
for finding in findings:
|
||||||
|
if not _dismissed(finding):
|
||||||
|
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
|
||||||
|
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
|
||||||
|
updated = []
|
||||||
|
for number, card in enumerate(cards, 1):
|
||||||
|
own = [metric for metric in metrics if metric.card == number]
|
||||||
|
updated.append({
|
||||||
|
**card,
|
||||||
|
"metrics": [metric.model_dump(mode="json") for metric in own],
|
||||||
|
"findings": [finding.model_dump(mode="json")
|
||||||
|
for finding in findings if finding.card == number],
|
||||||
|
"score_reviewed": GostResult(metrics=own).score,
|
||||||
|
})
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _rescore(report: dict, findings: list[Finding]) -> dict:
|
||||||
|
metrics = reviewed_metrics(
|
||||||
|
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
|
||||||
|
_instructor_weight(report),
|
||||||
|
)
|
||||||
|
score = GostResult(metrics=metrics).score
|
||||||
|
fields = {
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||||
|
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||||
|
"competencies": [item.model_dump() for item in radar(metrics)],
|
||||||
|
"score_reviewed": score,
|
||||||
|
}
|
||||||
|
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
|
||||||
|
if not report.get("overridden_by"):
|
||||||
|
fields["score_final"] = score
|
||||||
|
updated = {**report, **fields}
|
||||||
|
if "summary" in report:
|
||||||
|
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
|
||||||
|
if report.get("card_results"):
|
||||||
|
updated["card_results"] = _cards(report["card_results"], metrics, findings)
|
||||||
|
archived = report.get("full_report")
|
||||||
|
if isinstance(archived, dict):
|
||||||
|
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
|
||||||
|
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
|
||||||
|
updated["full_report"] = {
|
||||||
|
**archived, **fields,
|
||||||
|
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
|
||||||
|
"failed_metrics": failed,
|
||||||
|
"passed": failed <= allowed,
|
||||||
|
"score_final": updated.get("score_final", archived.get("score_final")),
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _findings(report: dict) -> list[Finding]:
|
||||||
|
return [Finding.model_validate(item) for item in report.get("findings", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
|
||||||
|
"""Подтвердить или снять отметку. Решение дописывается, действует последнее."""
|
||||||
|
findings = _findings(report)
|
||||||
|
if not 0 <= index < len(findings):
|
||||||
|
raise LookupError(f"нет отметки {index}")
|
||||||
|
findings[index] = findings[index].model_copy(
|
||||||
|
update={"reviews": [*findings[index].reviews, review]})
|
||||||
|
return _rescore(report, findings)
|
||||||
|
|
||||||
|
|
||||||
|
def has_finding(report: dict, client_id) -> bool:
|
||||||
|
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
|
||||||
|
return client_id is not None and any(
|
||||||
|
finding.client_id == client_id for finding in _findings(report))
|
||||||
|
|
||||||
|
|
||||||
|
def add_finding(report: dict, finding: Finding) -> dict:
|
||||||
|
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
|
||||||
|
if finding.source is not FindingSource.INSTRUCTOR:
|
||||||
|
raise ValueError("вручную добавляется только отметка преподавателя")
|
||||||
|
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
|
||||||
|
if has_finding(report, finding.client_id):
|
||||||
|
return report
|
||||||
|
return _rescore(report, [*_findings(report), finding])
|
||||||
|
|
@ -31,10 +31,16 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||||
|
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
|
||||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
|
||||||
|
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
|
||||||
|
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
|
||||||
|
INSTRUCTOR_METRIC = "instructor_finding"
|
||||||
|
|
||||||
#: Вес метрики в детерминированной оценке.
|
#: Вес метрики в детерминированной оценке.
|
||||||
#:
|
#:
|
||||||
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
|
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
|
||||||
|
|
@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
|
||||||
"description_grammar": 1.0,
|
"description_grammar": 1.0,
|
||||||
"card_fill_time": 1.5,
|
"card_fill_time": 1.5,
|
||||||
"dds_work_time": 1.5,
|
"dds_work_time": 1.5,
|
||||||
|
"dds_report_reaction": 1.5,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TimeNorm:
|
class TimeNorm:
|
||||||
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
|
"""Формулировки норматива времени: нормативы различаются словами и кодом
|
||||||
только словами, расчёт и отметка E3 у них общие."""
|
отметки, расчёт вклада и формат факта у них общие."""
|
||||||
|
|
||||||
key: str
|
key: str
|
||||||
title: str
|
title: str
|
||||||
|
|
@ -32,6 +32,10 @@ class TimeNorm:
|
||||||
action: str
|
action: str
|
||||||
unmeasured: str
|
unmeasured: str
|
||||||
incomplete: str
|
incomplete: str
|
||||||
|
code: ErrorCode = ErrorCode.E3
|
||||||
|
source: FindingSource = FindingSource.TIMERS
|
||||||
|
competency: Competency = Competency.NORMS
|
||||||
|
ref: str = TIME_REF
|
||||||
|
|
||||||
|
|
||||||
CARD_FILL_TIME = TimeNorm(
|
CARD_FILL_TIME = TimeNorm(
|
||||||
|
|
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
|
||||||
def time_metric(
|
def time_metric(
|
||||||
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
|
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
|
||||||
) -> tuple[Metric, Finding | None]:
|
) -> tuple[Metric, Finding | None]:
|
||||||
"""Метрика норматива и отметка E3, если он не выполнен.
|
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
|
||||||
|
|
||||||
Незавершённая работа не получает временного вклада: быстрое, но брошенное
|
Незавершённая работа не получает временного вклада: быстрое, но брошенное
|
||||||
заполнение не должно выглядеть скоростью.
|
заполнение не должно выглядеть скоростью.
|
||||||
|
|
@ -64,13 +68,20 @@ def time_metric(
|
||||||
fact = norm.unmeasured
|
fact = norm.unmeasured
|
||||||
else:
|
else:
|
||||||
delta_ms = elapsed_ms - limit_ms
|
delta_ms = elapsed_ms - limit_ms
|
||||||
|
# Превышение до полусекунды при округлении до целых читалось бы
|
||||||
|
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
|
||||||
|
precise = 0 < delta_ms <= 500
|
||||||
|
|
||||||
|
def seconds(ms: int) -> str:
|
||||||
|
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
|
||||||
|
|
||||||
if delta_ms > 0:
|
if delta_ms > 0:
|
||||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
deviation = f"+{seconds(delta_ms)} с сверх норматива"
|
||||||
elif delta_ms < 0:
|
elif delta_ms < 0:
|
||||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||||
else:
|
else:
|
||||||
deviation = "точно в норматив"
|
deviation = "точно в норматив"
|
||||||
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
|
fact = f"{seconds(elapsed_ms)} с ({deviation})"
|
||||||
if not completed:
|
if not completed:
|
||||||
fact = f"{norm.incomplete}; {fact}"
|
fact = f"{norm.incomplete}; {fact}"
|
||||||
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
|
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
|
||||||
|
|
@ -80,7 +91,7 @@ def time_metric(
|
||||||
title=norm.title,
|
title=norm.title,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm_text,
|
norm=norm_text,
|
||||||
ref=TIME_REF,
|
ref=norm.ref,
|
||||||
passed=passed,
|
passed=passed,
|
||||||
weight=METRIC_WEIGHTS[norm.key],
|
weight=METRIC_WEIGHTS[norm.key],
|
||||||
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
|
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
|
||||||
|
|
@ -88,11 +99,12 @@ def time_metric(
|
||||||
if passed:
|
if passed:
|
||||||
return metric, None
|
return metric, None
|
||||||
return metric, Finding(
|
return metric, Finding(
|
||||||
code=ErrorCode.E3,
|
code=norm.code,
|
||||||
source=FindingSource.TIMERS,
|
source=norm.source,
|
||||||
summary=norm.summary,
|
summary=norm.summary,
|
||||||
fact=fact,
|
fact=fact,
|
||||||
norm=norm_text,
|
norm=norm_text,
|
||||||
ref=TIME_REF,
|
ref=norm.ref,
|
||||||
competency=Competency.NORMS,
|
competency=norm.competency,
|
||||||
|
metric_key=norm.key,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
|
||||||
services[0], ServiceStatus.ACCEPTED, command.comment,
|
services[0], ServiceStatus.ACCEPTED, command.comment,
|
||||||
author="диспетчер",
|
author="диспетчер",
|
||||||
)
|
)
|
||||||
|
session.consecutive_refusals = 0
|
||||||
except StatusError:
|
except StatusError:
|
||||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||||
case "card.status":
|
case "card.status":
|
||||||
|
|
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
|
||||||
# Первичный статус останавливает норматив 30 секунд.
|
# Первичный статус останавливает норматив 30 секунд.
|
||||||
if command.status in PRIMARY:
|
if command.status in PRIMARY:
|
||||||
card.on_event("card.ack")
|
card.on_event("card.ack")
|
||||||
|
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||||
|
# отказы, а не сумму за занятие.
|
||||||
|
session.consecutive_refusals = (
|
||||||
|
session.consecutive_refusals + 1
|
||||||
|
if command.status is ServiceStatus.DECLINED
|
||||||
|
else 0
|
||||||
|
)
|
||||||
if command.status in {
|
if command.status in {
|
||||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
||||||
}:
|
}:
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,14 @@ from app.session.hub import hub
|
||||||
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
||||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||||
from app.session.store import (
|
from app.session.store import (
|
||||||
|
FindingAdded,
|
||||||
|
FindingReviewed,
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
LessonResumed,
|
LessonResumed,
|
||||||
ScoreArchived,
|
ScoreArchived,
|
||||||
ScoreCalculated,
|
ScoreCalculated,
|
||||||
ScoreOverridden,
|
ScoreOverridden,
|
||||||
|
apply_finding_change,
|
||||||
apply_score_override,
|
apply_score_override,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
services = state.card_services(card)
|
services = state.card_services(card)
|
||||||
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
||||||
dds_decision = card.scenario.dds_decision
|
dds_decision = card.scenario.dds_decision
|
||||||
|
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
||||||
findings = evaluate_dispatcher(
|
findings = evaluate_dispatcher(
|
||||||
entries=card.status_log,
|
entries=card.status_log,
|
||||||
services=services,
|
services=services,
|
||||||
|
|
@ -55,10 +59,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
reply_text=card.reply_text,
|
reply_text=card.reply_text,
|
||||||
expected_decision=dds_decision.expected,
|
expected_decision=dds_decision.expected,
|
||||||
expected_decision_reason=dds_decision.reason,
|
expected_decision_reason=dds_decision.reason,
|
||||||
|
phone_reports=card.phone_reports,
|
||||||
|
report_reaction_ms=reaction_ms,
|
||||||
)
|
)
|
||||||
metrics = dispatcher_metrics(
|
metrics = dispatcher_metrics(
|
||||||
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
||||||
services=services,
|
services=services, report_reaction_ms=reaction_ms,
|
||||||
)
|
)
|
||||||
weighted = GostResult(metrics=metrics, findings=findings)
|
weighted = GostResult(metrics=metrics, findings=findings)
|
||||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||||
|
|
@ -101,9 +107,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
card_id=card.card_id,
|
card_id=card.card_id,
|
||||||
scenario_id=card.scenario.id,
|
scenario_id=card.scenario.id,
|
||||||
reply_text=card.reply_text,
|
reply_text=card.reply_text,
|
||||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
|
||||||
|
"card": number})
|
||||||
for metric in weighted.metrics],
|
for metric in weighted.metrics],
|
||||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
|
||||||
|
"card": number})
|
||||||
for finding in weighted.findings],
|
for finding in weighted.findings],
|
||||||
actions=actions,
|
actions=actions,
|
||||||
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
||||||
|
|
@ -247,3 +255,14 @@ def override_score(state, override: ScoreOverridden) -> None:
|
||||||
hub.record(state.session_id, override)
|
hub.record(state.session_id, override)
|
||||||
state.score = apply_score_override(state.score, override)
|
state.score = apply_score_override(state.score, override)
|
||||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||||
|
|
||||||
|
|
||||||
|
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
|
||||||
|
"""Решение преподавателя по отметке — тем же путём, что правка итога.
|
||||||
|
|
||||||
|
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
|
||||||
|
разбор и строка аудита уходят одним commit.
|
||||||
|
"""
|
||||||
|
state.score = apply_finding_change(state.score, change)
|
||||||
|
hub.record(state.session_id, change)
|
||||||
|
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from app.domain.events import (
|
||||||
)
|
)
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
|
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
|
||||||
|
from app.session.timers import now_utc
|
||||||
|
|
||||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||||
#: очередь ограничена, переполнение роняет соединение, а не сессию.
|
#: очередь ограничена, переполнение роняет соединение, а не сессию.
|
||||||
|
|
@ -53,6 +54,9 @@ class SessionHub:
|
||||||
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
|
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
|
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||||
|
self._pending_adoptions: dict[UUID, SessionState] = {}
|
||||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||||
# Задачи, порождённые внутри операции, наследуют контекст; после
|
# Задачи, порождённые внутри операции, наследуют контекст; после
|
||||||
# закрытия операции их события идут напрямую (`_Operation.open`).
|
# закрытия операции их события идут напрямую (`_Operation.open`).
|
||||||
|
|
@ -116,6 +120,7 @@ class SessionHub:
|
||||||
|
|
||||||
def drop(self, session_id: UUID) -> None:
|
def drop(self, session_id: UUID) -> None:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
|
self._pending_adoptions.pop(session_id, None)
|
||||||
self.stop_ticker(session_id)
|
self.stop_ticker(session_id)
|
||||||
|
|
||||||
def live_count(self) -> int:
|
def live_count(self) -> int:
|
||||||
|
|
@ -135,9 +140,10 @@ class SessionHub:
|
||||||
if not self.store.persistent:
|
if not self.store.persistent:
|
||||||
return 0
|
return 0
|
||||||
restored = await self.store.restore_active()
|
restored = await self.store.restore_active()
|
||||||
|
adopted = 0
|
||||||
for state in restored:
|
for state in restored:
|
||||||
self._adopt(state)
|
adopted += await self._adopt(state)
|
||||||
return len(restored)
|
return adopted
|
||||||
|
|
||||||
async def maintain_lease(self) -> None:
|
async def maintain_lease(self) -> None:
|
||||||
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
|
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
|
||||||
|
|
@ -154,6 +160,18 @@ class SessionHub:
|
||||||
log.warning("продление lease занятия %s не удалось", state.session_id,
|
log.warning("продление lease занятия %s не удалось", state.session_id,
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
await self.fence(state)
|
await self.fence(state)
|
||||||
|
for state in list(self._pending_adoptions.values()):
|
||||||
|
try:
|
||||||
|
await self.store.commit(state)
|
||||||
|
except SessionLeaseLost:
|
||||||
|
self._pending_adoptions.pop(state.session_id, None)
|
||||||
|
except Exception:
|
||||||
|
log.warning("повторное сохранение занятия %s после takeover не удалось",
|
||||||
|
state.session_id, exc_info=True)
|
||||||
|
else:
|
||||||
|
self._pending_adoptions.pop(state.session_id, None)
|
||||||
|
self.register(state)
|
||||||
|
self.start_ticker(state.session_id)
|
||||||
try:
|
try:
|
||||||
claimed = await self.store.claim_expired()
|
claimed = await self.store.claim_expired()
|
||||||
except Exception: # следующий оборот попробует снова
|
except Exception: # следующий оборот попробует снова
|
||||||
|
|
@ -163,17 +181,32 @@ class SessionHub:
|
||||||
current = self._sessions.get(state.session_id)
|
current = self._sessions.get(state.session_id)
|
||||||
if current is not None and not current.lease_fenced:
|
if current is not None and not current.lease_fenced:
|
||||||
continue
|
continue
|
||||||
self._adopt(state)
|
await self._adopt(state)
|
||||||
|
|
||||||
async def supervise_lease(self, interval: float) -> None:
|
async def supervise_lease(self, interval: float) -> None:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
await self.maintain_lease()
|
await self.maintain_lease()
|
||||||
|
|
||||||
def _adopt(self, state: SessionState) -> None:
|
async def _adopt(self, state: SessionState) -> bool:
|
||||||
self.stop_ticker(state.session_id)
|
self.stop_ticker(state.session_id)
|
||||||
|
if state.socket_connected_at_checkpoint:
|
||||||
|
# Разрыв произошёл при потере узла; старое время подключения не
|
||||||
|
# доказывает, что курсант отсутствовал всё это время.
|
||||||
|
state.socket_last_seen_at = now_utc()
|
||||||
|
state.socket_connected_at_checkpoint = False
|
||||||
|
try:
|
||||||
|
await self.store.commit(state)
|
||||||
|
except SessionLeaseLost:
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
|
||||||
|
self._pending_adoptions[state.session_id] = state
|
||||||
|
return False
|
||||||
|
self._pending_adoptions.pop(state.session_id, None)
|
||||||
self.register(state)
|
self.register(state)
|
||||||
self.start_ticker(state.session_id)
|
self.start_ticker(state.session_id)
|
||||||
|
return True
|
||||||
|
|
||||||
async def save_all(self) -> None:
|
async def save_all(self) -> None:
|
||||||
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
|
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
|
||||||
|
|
@ -285,6 +318,44 @@ class SessionHub:
|
||||||
def station(self, session_id: UUID):
|
def station(self, session_id: UUID):
|
||||||
return self._subscribe(self._stations, session_id)
|
return self._subscribe(self._stations, session_id)
|
||||||
|
|
||||||
|
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
|
||||||
|
state = self.get(session_id)
|
||||||
|
if state is None or state.ended or state.dds_phase != station:
|
||||||
|
return
|
||||||
|
async with self.operation(session_id):
|
||||||
|
state.socket_last_seen_at = now_utc()
|
||||||
|
present = self._trainee_stations if station else self._trainee_calls
|
||||||
|
state.socket_connected_at_checkpoint = bool(present.get(session_id))
|
||||||
|
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def trainee_socket(
|
||||||
|
self, session_id: UUID, *, station: bool, trainee: bool,
|
||||||
|
) -> AsyncIterator[asyncio.Queue]:
|
||||||
|
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
|
||||||
|
registry = self._stations if station else self._trainees
|
||||||
|
presence = self._trainee_stations if station else self._trainee_calls
|
||||||
|
with self._subscribe(registry, session_id) as queue:
|
||||||
|
if trainee:
|
||||||
|
presence.setdefault(session_id, set()).add(queue)
|
||||||
|
try:
|
||||||
|
if trainee:
|
||||||
|
try:
|
||||||
|
await self._record_presence(session_id, station=station)
|
||||||
|
except Exception:
|
||||||
|
if not self.is_lease_fenced(session_id):
|
||||||
|
raise
|
||||||
|
yield queue
|
||||||
|
finally:
|
||||||
|
if trainee:
|
||||||
|
presence[session_id].discard(queue)
|
||||||
|
if not presence[session_id]:
|
||||||
|
presence.pop(session_id)
|
||||||
|
try:
|
||||||
|
await self._record_presence(session_id, station=station)
|
||||||
|
except Exception:
|
||||||
|
if not self.is_lease_fenced(session_id):
|
||||||
|
raise
|
||||||
|
|
||||||
# ── вещание ──
|
# ── вещание ──
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -314,6 +385,12 @@ class SessionHub:
|
||||||
def observer_count(self, session_id: UUID) -> int:
|
def observer_count(self, session_id: UUID) -> int:
|
||||||
return len(self._observers.get(session_id, set()))
|
return len(self._observers.get(session_id, set()))
|
||||||
|
|
||||||
|
def station_connected(self, session_id: UUID) -> bool:
|
||||||
|
return bool(self._trainee_stations.get(session_id))
|
||||||
|
|
||||||
|
def trainee_connected(self, session_id: UUID) -> bool:
|
||||||
|
return bool(self._trainee_calls.get(session_id))
|
||||||
|
|
||||||
# ── такт таймеров ──
|
# ── такт таймеров ──
|
||||||
|
|
||||||
def start_ticker(self, session_id: UUID) -> None:
|
def start_ticker(self, session_id: UUID) -> None:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.state import SessionState, now_utc
|
from app.session.state import SessionState, now_utc
|
||||||
from app.session.store import (
|
from app.session.store import (
|
||||||
|
FindingAdded,
|
||||||
|
FindingReviewed,
|
||||||
HintRecorded,
|
HintRecorded,
|
||||||
LessonEnded,
|
LessonEnded,
|
||||||
LessonIdentity,
|
LessonIdentity,
|
||||||
|
|
@ -44,8 +46,10 @@ from app.session.store import (
|
||||||
SelfAssessed,
|
SelfAssessed,
|
||||||
SessionLeaseLost,
|
SessionLeaseLost,
|
||||||
UtteranceAppended,
|
UtteranceAppended,
|
||||||
|
apply_finding_change,
|
||||||
apply_score_override,
|
apply_score_override,
|
||||||
)
|
)
|
||||||
|
from app.scoring.review import has_finding
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
LEASE_SECONDS = 15
|
LEASE_SECONDS = 15
|
||||||
|
|
@ -251,6 +255,40 @@ class PostgresSessionStore:
|
||||||
detail=(f"{score.score_auto} → {record.score_final}; "
|
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||||
f"comment_chars={len(record.comment)}"),
|
f"comment_chars={len(record.comment)}"),
|
||||||
))
|
))
|
||||||
|
case FindingReviewed() | FindingAdded():
|
||||||
|
await db.flush()
|
||||||
|
score = await db.scalar(
|
||||||
|
select(Score).where(Score.session_id == session_id).with_for_update()
|
||||||
|
)
|
||||||
|
if score is None:
|
||||||
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
|
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
|
||||||
|
if (isinstance(record, FindingAdded)
|
||||||
|
and has_finding(score.report or {}, record.finding.client_id)):
|
||||||
|
return
|
||||||
|
before = score.score_final
|
||||||
|
findings = (score.report or {}).get("findings", [])
|
||||||
|
score.report = apply_finding_change(dict(score.report or {}), record)
|
||||||
|
score.score_final = score.report.get("score_final", score.score_final)
|
||||||
|
if isinstance(record, FindingReviewed):
|
||||||
|
code = (findings[record.index].get("code", "?")
|
||||||
|
if 0 <= record.index < len(findings) else "?")
|
||||||
|
actor, action = record.review.author, "finding.review"
|
||||||
|
what = (f"#{record.index} {code} {record.review.decision.value}; "
|
||||||
|
f"причина: {record.review.reason}")
|
||||||
|
else:
|
||||||
|
finding = record.finding
|
||||||
|
actor, action = finding.author or "instructor", "finding.add"
|
||||||
|
where = f", карточка {finding.card}" if finding.card is not None else ""
|
||||||
|
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
|
||||||
|
f"норма: {finding.norm or ''}")
|
||||||
|
# Решение по отметке меняет чужой балл: аудит сам по себе должен
|
||||||
|
# ответить «кто, когда, что и почему», даже если разбор потом правили.
|
||||||
|
db.add(AuditLog(
|
||||||
|
actor=actor, role=record.role, action=action,
|
||||||
|
object_id=str(session_id),
|
||||||
|
detail=f"{what}; {before} → {score.score_final}",
|
||||||
|
))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,7 @@ async def score_session(
|
||||||
norm="грамматически корректное описание",
|
norm="грамматически корректное описание",
|
||||||
ref="критерий занятия; правила русского языка",
|
ref="критерий занятия; правила русского языка",
|
||||||
competency=Competency.COMMUNICATION,
|
competency=Competency.COMMUNICATION,
|
||||||
|
metric_key="description_grammar",
|
||||||
))
|
))
|
||||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||||
|
|
@ -218,6 +219,7 @@ async def score_session(
|
||||||
if live_card is not None:
|
if live_card is not None:
|
||||||
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
||||||
services = list(snapshot.call_card_services)
|
services = list(snapshot.call_card_services)
|
||||||
|
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
|
||||||
result.findings.extend(evaluate_dispatcher(
|
result.findings.extend(evaluate_dispatcher(
|
||||||
entries=live_card.status_log,
|
entries=live_card.status_log,
|
||||||
services=services,
|
services=services,
|
||||||
|
|
@ -227,10 +229,12 @@ async def score_session(
|
||||||
reply_text=live_card.reply_text,
|
reply_text=live_card.reply_text,
|
||||||
expected_decision=scenario.dds_decision.expected,
|
expected_decision=scenario.dds_decision.expected,
|
||||||
expected_decision_reason=scenario.dds_decision.reason,
|
expected_decision_reason=scenario.dds_decision.reason,
|
||||||
|
phone_reports=live_card.phone_reports,
|
||||||
|
report_reaction_ms=reaction_ms,
|
||||||
))
|
))
|
||||||
result.metrics.extend(dispatcher_metrics(
|
result.metrics.extend(dispatcher_metrics(
|
||||||
live_card, decision_limit_ms, scenario.dds_decision.expected,
|
live_card, decision_limit_ms, scenario.dds_decision.expected,
|
||||||
scenario.dds_decision.reason, services=services,
|
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
|
||||||
))
|
))
|
||||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,15 @@ class PersistedSession(BaseModel):
|
||||||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||||||
processed_station_commands: list[str] = Field(default_factory=list)
|
processed_station_commands: list[str] = Field(default_factory=list)
|
||||||
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
|
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
|
||||||
|
#: Подряд идущих первичных отказов без принятой карточки между ними —
|
||||||
|
#: сигнал реестра преподавателя, не влияет на балл.
|
||||||
|
consecutive_refusals: int = 0
|
||||||
|
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
|
||||||
|
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
|
||||||
|
socket_last_seen_at: datetime | None = None
|
||||||
|
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
|
||||||
|
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
|
||||||
|
socket_connected_at_checkpoint: bool = False
|
||||||
|
|
||||||
@field_validator("processed_station_commands")
|
@field_validator("processed_station_commands")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -241,6 +250,9 @@ class SessionState(PersistedSession):
|
||||||
self.dispatched_at = now_utc()
|
self.dispatched_at = now_utc()
|
||||||
if self.exercise is Exercise.CALL:
|
if self.exercise is Exercise.CALL:
|
||||||
self._receive_call_card()
|
self._receive_call_card()
|
||||||
|
if self.handoff_to_dds:
|
||||||
|
self.socket_last_seen_at = self.dispatched_at
|
||||||
|
self.socket_connected_at_checkpoint = False
|
||||||
return self.dispatched_card
|
return self.dispatched_card
|
||||||
|
|
||||||
def _receive_call_card(self) -> None:
|
def _receive_call_card(self) -> None:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ from typing import Protocol
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import TranscriptEntry
|
from app.domain.events import TranscriptEntry
|
||||||
|
from app.domain.taxonomy import Finding, FindingReview
|
||||||
|
from app.scoring.review import add_finding, review_finding
|
||||||
from app.session.checkpoint import dump_state
|
from app.session.checkpoint import dump_state
|
||||||
from app.session.state import SessionState
|
from app.session.state import SessionState
|
||||||
|
|
||||||
|
|
@ -90,6 +92,14 @@ class LessonPaused:
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FindingReviewed:
|
||||||
|
"""Преподаватель подтвердил или снял отметку разбора."""
|
||||||
|
index: int
|
||||||
|
review: FindingReview
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LessonResumed:
|
class LessonResumed:
|
||||||
at: datetime
|
at: datetime
|
||||||
|
|
@ -99,9 +109,23 @@ class LessonResumed:
|
||||||
paused_ms: int
|
paused_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FindingAdded:
|
||||||
|
"""Преподаватель добавил свою отметку (`source=instructor`)."""
|
||||||
|
finding: Finding
|
||||||
|
role: str
|
||||||
|
|
||||||
|
|
||||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||||
| LessonPaused | LessonResumed)
|
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
|
||||||
|
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
|
||||||
|
if isinstance(change, FindingReviewed):
|
||||||
|
return review_finding(report, change.index, change.review)
|
||||||
|
return add_finding(report, change.finding)
|
||||||
|
|
||||||
|
|
||||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||||
|
|
@ -217,6 +241,10 @@ class MemorySessionStore:
|
||||||
if session_id not in self.scores:
|
if session_id not in self.scores:
|
||||||
raise LookupError(f"нет оценки занятия {session_id}")
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
||||||
|
elif isinstance(record, FindingReviewed | FindingAdded):
|
||||||
|
if session_id not in self.scores:
|
||||||
|
raise LookupError(f"нет оценки занятия {session_id}")
|
||||||
|
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
|
||||||
self.records.setdefault(session_id, []).extend(records)
|
self.records.setdefault(session_id, []).extend(records)
|
||||||
|
|
||||||
def snapshot(self, session_id: UUID) -> dict | None:
|
def snapshot(self, session_id: UUID) -> dict | None:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
os.environ.setdefault("DEV_AUTH_BYPASS", "true")
|
||||||
|
# Тесты ждут локальную модель по умолчанию. Личный backend/.env с облачным
|
||||||
|
# провайдером иначе валит проверки офлайн-гарда; переменные окружения
|
||||||
|
# перекрывают .env, а живые проверки test_llm.py выставляют свои поверх.
|
||||||
|
# Рекомендации Vikhr выключены: на WSL2 подключение к закрытому loopback-порту
|
||||||
|
# висит до таймаута, и финиш занятия ждал бы 6 с. Тесты рекомендаций
|
||||||
|
# подменяют coach сами.
|
||||||
|
os.environ.update({
|
||||||
|
"LLM_PROVIDER": "local",
|
||||||
|
"LLM_BASE_URL": "http://127.0.0.1:18080/v1",
|
||||||
|
"LLM_API_KEY": "",
|
||||||
|
"LLM_MODEL_CALLER": "Qwen3-1.7B",
|
||||||
|
"LLM_MODEL_JUDGE": "",
|
||||||
|
"ASSESSMENT_FEEDBACK_ENABLED": "false",
|
||||||
|
})
|
||||||
|
|
||||||
import pytest # noqa: E402
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
|
@ -36,6 +50,19 @@ def postgres_access():
|
||||||
pytest.skip(f"PostgreSQL unavailable ({exc})")
|
pytest.skip(f"PostgreSQL unavailable ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def template_caller():
|
||||||
|
"""Звонящий без модели: сквозные тесты занятия ждут заготовки и офлайн-
|
||||||
|
ответы. С моделью по умолчанию (loopback Qwen) фабрика выбрала бы
|
||||||
|
LlmCaller и ждала неподнятый сервер."""
|
||||||
|
from app.dialog import factory
|
||||||
|
|
||||||
|
settings = get_settings().model_copy(update={"llm_provider": "disabled"})
|
||||||
|
with pytest.MonkeyPatch.context() as patch:
|
||||||
|
patch.setattr(factory, "get_settings", lambda: settings)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def _dev_auth():
|
def _dev_auth():
|
||||||
"""Флаг обхода читается один раз при создании настроек."""
|
"""Флаг обхода читается один раз при создании настроек."""
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,40 @@ def test_peer_node_generation_sync_closes_revoked_websocket(monkeypatch):
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_sync_keeps_revocation_of_login_missing_from_db(monkeypatch):
|
||||||
|
# Демо-логины синтетические, в users их нет. Сброс поколения к 0 после
|
||||||
|
# выхода вернул бы силу cookie, выданной до выхода.
|
||||||
|
login = "demo-instructor"
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, _query):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
auth.prime_generations({})
|
||||||
|
who = auth._demo_principal(Role.INSTRUCTOR)
|
||||||
|
request = SimpleNamespace(session={})
|
||||||
|
auth._issue_session(request, who)
|
||||||
|
stale = dict(request.session)
|
||||||
|
|
||||||
|
auth.invalidate_login(login)
|
||||||
|
asyncio.run(auth.sync_generations())
|
||||||
|
|
||||||
|
assert auth._session_principal(stale) is None
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
def test_revocation_does_not_log_error_if_socket_already_disconnected():
|
def test_revocation_does_not_log_error_if_socket_already_disconnected():
|
||||||
class DisconnectedSocket:
|
class DisconnectedSocket:
|
||||||
async def close(self, **_kwargs):
|
async def close(self, **_kwargs):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
client,
|
client,
|
||||||
criteria={
|
criteria={
|
||||||
"decision_time_limit_seconds": 45,
|
"decision_time_limit_seconds": 45,
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
"allowed_errors": 50,
|
"allowed_errors": 50,
|
||||||
"require_correct_grammar": False,
|
"require_correct_grammar": False,
|
||||||
"score_weights": {"dds_ack": 3.5},
|
"score_weights": {"dds_ack": 3.5},
|
||||||
|
|
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
"decision_time_limit_seconds": 45,
|
"decision_time_limit_seconds": 45,
|
||||||
"card_fill_time_limit_seconds": 180,
|
"card_fill_time_limit_seconds": 180,
|
||||||
"dds_card_work_time_limit_seconds": 180,
|
"dds_card_work_time_limit_seconds": 180,
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
"allowed_errors": 50,
|
"allowed_errors": 50,
|
||||||
"require_correct_grammar": False,
|
"require_correct_grammar": False,
|
||||||
"score_weights": {"dds_ack": 3.5},
|
"score_weights": {"dds_ack": 3.5},
|
||||||
|
|
@ -797,6 +799,118 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
|
||||||
]
|
]
|
||||||
assert all(report.crew == crew for report in state.desk.active.phone_reports)
|
assert all(report.crew == crew for report in state.desk.active.phone_reports)
|
||||||
assert not score["findings"]
|
assert not score["findings"]
|
||||||
|
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
|
||||||
|
assert len(reaction) == 1 and reaction[0]["passed"], "каждый доклад отмечен вовремя"
|
||||||
|
assert "с нарушением 0 из 4 докладов" in reaction[0]["fact"]
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_COMMENT = "Основание: доклад бригады.\nСведения: передано старшему бригады."
|
||||||
|
|
||||||
|
|
||||||
|
def accept_with_crew(station):
|
||||||
|
read_until(station, "card.received")
|
||||||
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
|
service = snapshot["services"][0]
|
||||||
|
crew = next(option for option in snapshot["crew_options"]
|
||||||
|
if option.startswith(service + " — "))
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": "accepted",
|
||||||
|
"comment": STATUS_COMMENT})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({"type": "crew.select", "crew": crew})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
def send_status(station, service, status):
|
||||||
|
station.send_json({"type": "card.status", "service": service, "status": status,
|
||||||
|
"comment": STATUS_COMMENT})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("reaction", "passed"), [
|
||||||
|
("fast", True), ("slow", False), ("missing", False), ("early", True),
|
||||||
|
])
|
||||||
|
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
|
||||||
|
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
|
||||||
|
session_id, control = start(client, criteria={
|
||||||
|
"dds_report_reaction_limit_seconds": 20,
|
||||||
|
"score_weights": {"dds_report_reaction": 3.5},
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
service = accept_with_crew(station)
|
||||||
|
if reaction == "early":
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
complete_phone_call(station, state, "dispatched")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
if reaction in {"fast", "slow"}:
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
if reaction == "slow":
|
||||||
|
# Доклад на 70 с раньше статуса: время доклада — серверное.
|
||||||
|
state.desk.active.phone_reports[0].at -= timedelta(seconds=70)
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
metrics = [item for item in score["metrics"] if item["key"] == "dds_report_reaction"]
|
||||||
|
assert len(metrics) == 1
|
||||||
|
metric = metrics[0]
|
||||||
|
assert metric["passed"] is passed
|
||||||
|
assert metric["weight"] == 3.5
|
||||||
|
assert metric["norm"] == "отметить статус по докладу за 20 с"
|
||||||
|
assert "«Начало реагирования» по докладу бригады" in metric["fact"]
|
||||||
|
findings = [item for item in score["findings"]
|
||||||
|
if item["metric_key"] == "dds_report_reaction"]
|
||||||
|
assert [item["code"] for item in findings] == ([] if passed else ["D6"])
|
||||||
|
assert all(item["competency"] == "norms" for item in findings)
|
||||||
|
if reaction == "slow":
|
||||||
|
assert "70 с (+50 с сверх норматива)" in metric["fact"]
|
||||||
|
assert findings[0]["fact"] == metric["fact"]
|
||||||
|
if reaction == "missing":
|
||||||
|
assert metric["fact"].endswith(": не отмечен") and metric["credit"] == 0.0
|
||||||
|
if reaction == "early":
|
||||||
|
assert metric["fact"].endswith("отмечен не позже доклада")
|
||||||
|
|
||||||
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
|
||||||
|
assert [item for item in report["metrics"]
|
||||||
|
if item["key"] == "dds_report_reaction"] == metrics
|
||||||
|
if reaction == "slow":
|
||||||
|
csv_text = client.get(f"/api/sessions/{session_id}/report.csv").content.decode("utf-8-sig")
|
||||||
|
assert "Лимит реакции на доклад бригады" in csv_text
|
||||||
|
assert "70 с (+50 с сверх норматива)" in csv_text
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
|
||||||
|
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
|
||||||
|
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
|
||||||
|
session_id, control = start(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
service = accept_with_crew(station)
|
||||||
|
complete_phone_call(station, state, "dispatched")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
send_status(station, service, "responding")
|
||||||
|
complete_phone_call(station, state, "arrived")
|
||||||
|
read_until(station, "station.state")
|
||||||
|
send_status(station, service, "refused")
|
||||||
|
station.send_json({"type": "station.finish"})
|
||||||
|
read_until(station, "score.ready")
|
||||||
|
|
||||||
|
score = wait_for(lambda: hub.get(session_id).score)
|
||||||
|
assert [report.phase for report in state.desk.active.phone_reports] == [
|
||||||
|
"dispatched", "arrived",
|
||||||
|
]
|
||||||
|
metric = next(item for item in score["metrics"] if item["key"] == "dds_report_reaction")
|
||||||
|
assert metric["passed"], "прибытие при отказе не требуется"
|
||||||
|
assert "о выезде" in metric["fact"] and "докладов" not in metric["fact"]
|
||||||
|
assert not [item for item in score["findings"] if item["code"] == "D6"]
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from app.domain.statuses import ServiceStatus, StatusEntry
|
from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
|
||||||
from app.domain.taxonomy import ErrorCode
|
from app.domain.taxonomy import Competency, ErrorCode, FindingSource
|
||||||
from app.scenarios.loader import load_file
|
from app.scenarios.loader import load_file
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
|
from app.scoring.timing import time_credit
|
||||||
from app.session.dds import DdsLiveCard, build_card
|
from app.session.dds import DdsLiveCard, build_card
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
|
|
@ -294,3 +295,158 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
|
||||||
deadline_ms=30_000, elapsed_ms=0,
|
deadline_ms=30_000, elapsed_ms=0,
|
||||||
)
|
)
|
||||||
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
assert ErrorCode.D6 not in [finding.code for finding in findings]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Реакция на доклад бригады (lct-37) ──
|
||||||
|
|
||||||
|
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
|
||||||
|
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
|
||||||
|
|
||||||
|
|
||||||
|
def at_second(seconds: int) -> datetime:
|
||||||
|
return T0 + timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
|
||||||
|
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
|
||||||
|
|
||||||
|
|
||||||
|
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
|
||||||
|
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
|
||||||
|
text="доклад", at=at_second(seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def reaction(status_log, reports, limit_ms=45_000):
|
||||||
|
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||||
|
dispatched_at=T0)
|
||||||
|
card.phone_reports = reports
|
||||||
|
findings = evaluate_dispatcher(
|
||||||
|
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
|
||||||
|
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
|
||||||
|
report_reaction_ms=limit_ms,
|
||||||
|
)
|
||||||
|
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
|
||||||
|
report_reaction_ms=limit_ms)
|
||||||
|
if m.key == "dds_report_reaction"), None)
|
||||||
|
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
|
||||||
|
|
||||||
|
|
||||||
|
def full_run(arrived_at: int) -> list[StatusEntry]:
|
||||||
|
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||||
|
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
|
||||||
|
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_soon_after_crew_report_passes():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert findings == []
|
||||||
|
assert metric.passed and metric.weight == 1.5
|
||||||
|
assert metric.fact == (
|
||||||
|
"статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: "
|
||||||
|
"10 с (на 35 с быстрее норматива)"
|
||||||
|
)
|
||||||
|
assert metric.credit == time_credit(10_000, 45_000)
|
||||||
|
assert metric.title == f"{SERVICES[0]}: реакция на доклад бригады"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
|
||||||
|
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
|
||||||
|
assert findings[0].norm == metric.norm == "отметить статус по докладу за 45 с"
|
||||||
|
assert findings[0].competency is Competency.NORMS, "отметка и метрика в одной компетенции"
|
||||||
|
assert findings[0].source is FindingSource.DISPATCHER
|
||||||
|
assert findings[0].service == SERVICES[0]
|
||||||
|
assert not metric.passed and metric.fact == findings[0].fact
|
||||||
|
assert metric.credit == time_credit(70_000, 45_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_violation_by_a_fraction_of_a_second_is_not_shown_as_the_limit():
|
||||||
|
status_log = full_run(arrived_at=170)
|
||||||
|
status_log[2] = status_log[2].model_copy(
|
||||||
|
update={"at": at_second(100) + timedelta(milliseconds=45_400)},
|
||||||
|
)
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert not metric.passed
|
||||||
|
assert "45.4 с (+0.4 с сверх норматива)" in findings[0].fact
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_status_after_crew_report_is_d6():
|
||||||
|
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
|
||||||
|
assert not metric.passed and metric.credit == 0.0
|
||||||
|
assert metric.fact == "статус «Прибытие» по докладу бригады «Бригада 12» о прибытии: не отмечен"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_takes_the_worst_report_and_counts_violations():
|
||||||
|
reports = [crew_report("dispatched", 10), crew_report("arrived", 100),
|
||||||
|
crew_report("working", 175)]
|
||||||
|
|
||||||
|
findings, metric = reaction(full_run(arrived_at=170), reports)
|
||||||
|
|
||||||
|
assert len(findings) == 1, "одна отметка на службу, как у других нормативов"
|
||||||
|
assert "о прибытии: 70 с" in metric.fact
|
||||||
|
assert "с нарушением 1 из 3 докладов" in metric.fact
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_set_before_the_report_and_custom_limit():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
|
||||||
|
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
|
||||||
|
assert metric.fact.endswith("отмечен не позже доклада") and metric.credit == 1.0
|
||||||
|
|
||||||
|
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
|
||||||
|
assert strict[0].norm == "отметить статус по докладу за 20 с"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_crew_reports_means_no_reaction_metric():
|
||||||
|
findings, metric = reaction(full_run(arrived_at=110), [])
|
||||||
|
|
||||||
|
assert findings == [] and metric is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_after_crew_report_is_neither_d6_nor_failed_metric():
|
||||||
|
status_log = [
|
||||||
|
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
|
||||||
|
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||||
|
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||||
|
]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert findings == [], "работ не было — статус «Прибытие» отмечать не нужно"
|
||||||
|
assert metric is None, "метрика не снижает балл без отметки"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refusal_keeps_late_reaction_on_reported_status():
|
||||||
|
status_log = [
|
||||||
|
mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 80),
|
||||||
|
StatusEntry(service=SERVICES[0], status=ServiceStatus.REFUSED, at=at_second(120),
|
||||||
|
comment="Основание: доклад старшего.\nСведения: бригаде отказано в доступе."),
|
||||||
|
]
|
||||||
|
|
||||||
|
findings, metric = reaction(status_log, [crew_report("dispatched", 10),
|
||||||
|
crew_report("arrived", 100)])
|
||||||
|
|
||||||
|
assert [f.code for f in findings] == [ErrorCode.D6]
|
||||||
|
assert not metric.passed and "о выезде: 70 с" in metric.fact
|
||||||
|
assert "докладов" not in metric.fact, "неотмеченное прибытие при отказе не считается"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfinished_work_after_completion_report_names_the_report():
|
||||||
|
status_log = full_run(arrived_at=110)[:-1]
|
||||||
|
findings = evaluate_dispatcher(
|
||||||
|
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
|
||||||
|
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
|
||||||
|
)
|
||||||
|
|
||||||
|
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
|
||||||
|
assert "бригада доложила о завершении работ" in unfinished.fact
|
||||||
|
|
|
||||||
644
backend/tests/test_finding_review.py
Normal file
644
backend/tests/test_finding_review.py
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
"""Разбор по отметкам (lct-35): преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
|
||||||
|
Балл пересчитывается теми же метриками и весами: снятая отметка не штрафует
|
||||||
|
свою метрику, ручная — штрафует. Автоматическая оценка остаётся рядом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.api.http import sessions
|
||||||
|
from app.domain.events import Metric
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.domain.taxonomy import (
|
||||||
|
ErrorCode,
|
||||||
|
Finding,
|
||||||
|
FindingDecision,
|
||||||
|
FindingReview,
|
||||||
|
FindingSource,
|
||||||
|
)
|
||||||
|
from app.main import app
|
||||||
|
from app.scoring.export import to_csv
|
||||||
|
from app.scoring.review import INSTRUCTOR_METRIC, add_finding, review_finding
|
||||||
|
from app.session import finish as finish_module
|
||||||
|
from app.session.hub import hub
|
||||||
|
from app.session.store import FindingAdded, FindingReviewed, MemorySessionStore
|
||||||
|
|
||||||
|
from tests.test_dds_desk_scores import dds_exercise_early_finish
|
||||||
|
|
||||||
|
AT = datetime(2026, 9, 27, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def review(decision: FindingDecision) -> FindingReview:
|
||||||
|
return FindingReview(decision=decision, reason="доклад пришёл по рации", author="teacher", at=AT)
|
||||||
|
|
||||||
|
|
||||||
|
def report_with(metrics: list[Metric], findings: list[Finding]) -> dict:
|
||||||
|
return {
|
||||||
|
"score_auto": 50.0,
|
||||||
|
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||||
|
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||||
|
"full_report": {"criteria": {"allowed_errors": 0}, "score_auto": 50.0,
|
||||||
|
"score_final": 50.0, "failed_metrics": 1, "passed": False},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dds_metric(key: str, passed: bool, card: int = 1) -> Metric:
|
||||||
|
return Metric(key=key, title=key, fact="факт", norm="норма", passed=passed,
|
||||||
|
weight=1.0, card=card, service="01")
|
||||||
|
|
||||||
|
|
||||||
|
def dds_finding(code: ErrorCode, card: int = 1) -> Finding:
|
||||||
|
return Finding(code=code, source=FindingSource.DISPATCHER, summary=code.value,
|
||||||
|
fact="факт", norm="норма", card=card, service="01")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
|
||||||
|
[dds_finding(ErrorCode.D6)],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 100.0
|
||||||
|
assert updated["score_final"] == 100.0
|
||||||
|
assert updated["score_auto"] == 50.0, "автооценка остаётся рядом"
|
||||||
|
progress = updated["metrics"][1]
|
||||||
|
assert (progress["passed"], progress["passed_auto"]) == (True, False)
|
||||||
|
finding = updated["findings"][0]
|
||||||
|
assert finding["reviews"][-1]["decision"] == "dismissed"
|
||||||
|
assert finding["reviews"][-1]["reason"] == "доклад пришёл по рации"
|
||||||
|
archived = updated["full_report"]
|
||||||
|
assert (archived["score_final"], archived["failed_metrics"], archived["passed"]) == (100.0, 0, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_finding_and_other_card_keep_the_metric_failed():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_progress", False, card=1), dds_metric("dds_progress", False, card=2)],
|
||||||
|
[dds_finding(ErrorCode.D6, card=1), dds_finding(ErrorCode.D6, card=2)],
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmed = review_finding(report, 0, review(FindingDecision.CONFIRMED))
|
||||||
|
assert confirmed["score_reviewed"] == 0.0
|
||||||
|
|
||||||
|
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
assert [metric["passed"] for metric in dismissed["metrics"]] == [True, False], (
|
||||||
|
"снятие отметки первой карточки не трогает вторую"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_metric_stays_failed_while_one_of_its_findings_is_active():
|
||||||
|
report = report_with(
|
||||||
|
[dds_metric("dds_reply", False)],
|
||||||
|
[dds_finding(ErrorCode.D5), dds_finding(ErrorCode.D5)],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["metrics"][0]["passed"] is False
|
||||||
|
again = review_finding(updated, 1, review(FindingDecision.DISMISSED))
|
||||||
|
assert again["metrics"][0]["passed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_instructor_finding_penalizes_linked_metric_and_unlinked_code_adds_one():
|
||||||
|
report = report_with([dds_metric("dds_progress", True), dds_metric("dds_primary", True)], [])
|
||||||
|
manual = Finding(code=ErrorCode.D6, source=FindingSource.INSTRUCTOR, summary="Карточка 1: D6",
|
||||||
|
fact="«Прибыла» отмечено до доклада", norm="по факту доклада",
|
||||||
|
card=1, service="01", author="teacher", at=AT)
|
||||||
|
|
||||||
|
updated = add_finding(report, manual)
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 50.0
|
||||||
|
assert updated["metrics"][0]["passed"] is False
|
||||||
|
assert updated["metrics"][0]["passed_auto"] is True
|
||||||
|
assert updated["findings"][0]["source"] == "instructor"
|
||||||
|
|
||||||
|
# У D4 в методике нет метрики — ручная отметка всё равно должна штрафовать.
|
||||||
|
no_metric = manual.model_copy(update={"code": ErrorCode.D4})
|
||||||
|
extra = add_finding(report, no_metric)
|
||||||
|
assert [metric["key"] for metric in extra["metrics"]][-1] == INSTRUCTOR_METRIC
|
||||||
|
assert extra["score_reviewed"] == pytest.approx(66.7)
|
||||||
|
|
||||||
|
# Снятая ручная отметка не штрафует, и её метрика исчезает при пересчёте.
|
||||||
|
lifted = review_finding(extra, 0, review(FindingDecision.DISMISSED))
|
||||||
|
assert lifted["score_reviewed"] == 100.0
|
||||||
|
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in lifted["metrics"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_override_stays_final_after_finding_review():
|
||||||
|
report = {**report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)]),
|
||||||
|
"score_final": 70.0, "overridden_by": "teacher"}
|
||||||
|
|
||||||
|
updated = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert updated["score_reviewed"] == 100.0
|
||||||
|
assert updated["score_final"] == 70.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_finding_index_is_refused():
|
||||||
|
with pytest.raises(LookupError):
|
||||||
|
review_finding(report_with([], []), 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP: живое занятие ДДС ──
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lesson(monkeypatch):
|
||||||
|
"""Завершённое занятие ДДС преподавателя `dev` в памяти узла."""
|
||||||
|
|
||||||
|
class NoCoaching:
|
||||||
|
def model_dump(self, **_kwargs):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def no_coach(_metrics):
|
||||||
|
return NoCoaching()
|
||||||
|
|
||||||
|
async def audit_in_memory(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def no_database():
|
||||||
|
yield None
|
||||||
|
|
||||||
|
# Живое занятие в памяти: чтение отчёта не должно ходить в PostgreSQL.
|
||||||
|
monkeypatch.setattr(finish_module, "coach", no_coach)
|
||||||
|
monkeypatch.setattr(sessions, "audit_required", audit_in_memory)
|
||||||
|
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, no_database)
|
||||||
|
store = MemorySessionStore()
|
||||||
|
monkeypatch.setattr(hub, "store", store)
|
||||||
|
state = dds_exercise_early_finish()
|
||||||
|
state.owner_login = "dev"
|
||||||
|
with TestClient(app) as browser:
|
||||||
|
browser.post("/api/auth/dev-token")
|
||||||
|
hub.store = store
|
||||||
|
hub.register(state)
|
||||||
|
yield browser, state, store
|
||||||
|
|
||||||
|
|
||||||
|
def as_role(monkeypatch, role: Role, login: str = "dev") -> None:
|
||||||
|
def require(_request, *roles):
|
||||||
|
if roles and role not in roles:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
return Principal(login=login, full_name="Проверка", role=role,
|
||||||
|
trainee_id=uuid4() if role is Role.TRAINEE else None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions, "require", require)
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_dismisses_and_adds_finding_through_http(lesson):
|
||||||
|
browser, state, store = lesson
|
||||||
|
session_id = state.session_id
|
||||||
|
before = browser.get(f"/api/sessions/{session_id}/report").json()
|
||||||
|
target = next(index for index, finding in enumerate(before["findings"])
|
||||||
|
if finding["code"] == "D1")
|
||||||
|
|
||||||
|
dismissed = browser.post(
|
||||||
|
f"/api/sessions/{session_id}/findings/{target}/review",
|
||||||
|
json={"decision": "dismissed", "reason": "карточку выдали с опозданием"},
|
||||||
|
)
|
||||||
|
assert dismissed.status_code == 200, dismissed.text
|
||||||
|
body = dismissed.json()
|
||||||
|
assert body["findings"][target]["reviews"][-1]["decision"] == "dismissed"
|
||||||
|
assert body["findings"][target]["reviews"][-1]["author"] == "dev"
|
||||||
|
assert body["score_auto"] == before["score_auto"]
|
||||||
|
assert body["score_reviewed"] >= before["score_auto"]
|
||||||
|
|
||||||
|
added = browser.post(
|
||||||
|
f"/api/sessions/{session_id}/findings",
|
||||||
|
json={"code": "D5", "fact": "в комментарии нет адресата", "norm": "назвать адресата",
|
||||||
|
"card": 1},
|
||||||
|
)
|
||||||
|
assert added.status_code == 200, added.text
|
||||||
|
manual = added.json()["findings"][-1]
|
||||||
|
assert (manual["source"], manual["card"], manual["author"]) == ("instructor", 1, "dev")
|
||||||
|
assert manual["summary"].startswith("Карточка 1: ")
|
||||||
|
|
||||||
|
records = [record for _, batch in store.commits for record in batch]
|
||||||
|
assert [type(record) for record in records[-2:]] == [FindingReviewed, FindingAdded]
|
||||||
|
assert store.scores[session_id]["findings"][target]["reviews"][-1]["decision"] == "dismissed"
|
||||||
|
|
||||||
|
rows = to_csv(sessions.SessionReport.model_validate(added.json())).decode("utf-8-sig")
|
||||||
|
assert "карточку выдали с опозданием" in rows
|
||||||
|
assert "в комментарии нет адресата" in rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_requires_reason_and_existing_card(lesson):
|
||||||
|
browser, state, _store = lesson
|
||||||
|
session_id = state.session_id
|
||||||
|
|
||||||
|
blank = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": " "})
|
||||||
|
assert blank.status_code == 422
|
||||||
|
missing = browser.post(f"/api/sessions/{session_id}/findings/999/review",
|
||||||
|
json={"decision": "confirmed", "reason": "проверено"})
|
||||||
|
assert missing.status_code == 404
|
||||||
|
card = browser.post(f"/api/sessions/{session_id}/findings",
|
||||||
|
json={"code": "D1", "fact": "факт", "norm": "норма", "card": 99})
|
||||||
|
assert card.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role, login, status", [
|
||||||
|
(Role.TRAINEE, "trainee", 403),
|
||||||
|
(Role.INSTRUCTOR, "someone-else", 404),
|
||||||
|
])
|
||||||
|
def test_trainee_and_foreign_instructor_cannot_review(lesson, monkeypatch, role, login, status):
|
||||||
|
browser, state, store = lesson
|
||||||
|
commits = len(store.commits)
|
||||||
|
as_role(monkeypatch, role, login)
|
||||||
|
|
||||||
|
reviewed = browser.post(f"/api/sessions/{state.session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": "не согласен"})
|
||||||
|
added = browser.post(f"/api/sessions/{state.session_id}/findings",
|
||||||
|
json={"code": "D1", "fact": "факт", "norm": "норма"})
|
||||||
|
|
||||||
|
assert (reviewed.status_code, added.status_code) == (status, status)
|
||||||
|
assert len(store.commits) == commits, "отказ ничего не пишет"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_review_is_audited_in_postgres(postgres_access):
|
||||||
|
"""Решение и аудит — одна транзакция: строка Score и AuditLog вместе."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import get_sessionmaker
|
||||||
|
from app.db.models import AuditLog, Score
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.store import LessonRequest
|
||||||
|
|
||||||
|
store = PostgresSessionStore(get_sessionmaker())
|
||||||
|
session_id = uuid4()
|
||||||
|
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.domain.events import Exercise, SessionMode
|
||||||
|
|
||||||
|
def build(_identity):
|
||||||
|
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||||
|
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||||
|
exercise=Exercise.DDS)
|
||||||
|
|
||||||
|
await store.open(LessonRequest(
|
||||||
|
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||||
|
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
from app.session.store import ScoreCalculated
|
||||||
|
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
|
||||||
|
await store.commit_archived(session_id, [FindingReviewed(
|
||||||
|
index=0, role="instructor", review=review(FindingDecision.DISMISSED),
|
||||||
|
)])
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
audit = await db.scalar(select(AuditLog).where(
|
||||||
|
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
|
||||||
|
))
|
||||||
|
return score, audit
|
||||||
|
|
||||||
|
score, audit = asyncio.run(scenario())
|
||||||
|
assert score.score_auto == 50.0
|
||||||
|
assert score.score_final == 100.0
|
||||||
|
assert score.report["findings"][0]["reviews"][-1]["decision"] == "dismissed"
|
||||||
|
assert audit is not None and audit.actor == "teacher"
|
||||||
|
assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ── История решений, коды, карточки, веса ──
|
||||||
|
|
||||||
|
def test_repeated_decision_keeps_history_and_last_one_applies():
|
||||||
|
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||||
|
|
||||||
|
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
confirmed = review_finding(dismissed, 0, FindingReview(
|
||||||
|
decision=FindingDecision.CONFIRMED, reason="перепроверил запись", author="head", at=AT,
|
||||||
|
))
|
||||||
|
|
||||||
|
reviews = confirmed["findings"][0]["reviews"]
|
||||||
|
assert [(item["decision"], item["author"], item["reason"]) for item in reviews] == [
|
||||||
|
("dismissed", "teacher", "доклад пришёл по рации"),
|
||||||
|
("confirmed", "head", "перепроверил запись"),
|
||||||
|
]
|
||||||
|
assert confirmed["score_reviewed"] == 0.0, "действует последнее решение"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_saved_with_single_review_reads_as_history():
|
||||||
|
legacy = dds_finding(ErrorCode.D6).model_dump(mode="json")
|
||||||
|
legacy["review"] = review(FindingDecision.DISMISSED).model_dump(mode="json")
|
||||||
|
|
||||||
|
finding = Finding.model_validate(legacy)
|
||||||
|
|
||||||
|
assert [item.decision for item in finding.reviews] == [FindingDecision.DISMISSED]
|
||||||
|
assert finding.review.decision is FindingDecision.DISMISSED
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_code_in_handoff_penalizes_call_metric_not_instructor_metric():
|
||||||
|
"""Связка 112 → ДДС: E1 без карточки проваливает метрику 112, а не добавляет свою."""
|
||||||
|
call = Metric(key="checklist_completeness", title="Полнота опроса", fact="все", norm="все",
|
||||||
|
passed=True, weight=1.0)
|
||||||
|
report = report_with([call, dds_metric("dds_progress", True)], [])
|
||||||
|
manual = Finding(code=ErrorCode.E1, source=FindingSource.INSTRUCTOR, summary="E1",
|
||||||
|
fact="не уточнён подъезд", norm="уточнить подъезд", author="teacher", at=AT)
|
||||||
|
|
||||||
|
updated = add_finding(report, manual)
|
||||||
|
|
||||||
|
assert [metric["passed"] for metric in updated["metrics"]] == [False, True]
|
||||||
|
assert INSTRUCTOR_METRIC not in {metric["key"] for metric in updated["metrics"]}
|
||||||
|
assert updated["score_reviewed"] == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_codes_and_card_scores_follow_the_review():
|
||||||
|
card = {"card_id": str(uuid4()), "scenario_id": "s", "score_auto": 50.0, "reply_text": "",
|
||||||
|
"metrics": [dds_metric("dds_primary", True).model_dump(mode="json"),
|
||||||
|
dds_metric("dds_progress", False).model_dump(mode="json")],
|
||||||
|
"findings": [dds_finding(ErrorCode.D6).model_dump(mode="json")]}
|
||||||
|
report = {**report_with([dds_metric("dds_primary", True), dds_metric("dds_progress", False)],
|
||||||
|
[dds_finding(ErrorCode.D6)]),
|
||||||
|
"summary": {"hints": 2, "codes": {"D6": 1}}, "card_results": [card]}
|
||||||
|
report["full_report"]["card_results"] = [card]
|
||||||
|
|
||||||
|
dismissed = review_finding(report, 0, review(FindingDecision.DISMISSED))
|
||||||
|
|
||||||
|
assert dismissed["summary"] == {"hints": 2, "codes": {}}, "снятая отметка не ошибка группы"
|
||||||
|
for cards in (dismissed["card_results"], dismissed["full_report"]["card_results"]):
|
||||||
|
assert cards[0]["score_auto"] == 50.0
|
||||||
|
assert cards[0]["score_reviewed"] == 100.0
|
||||||
|
assert cards[0]["metrics"][1]["passed"] is True
|
||||||
|
assert cards[0]["findings"][0]["reviews"][-1]["decision"] == "dismissed"
|
||||||
|
|
||||||
|
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||||
|
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||||
|
author="teacher", at=AT)
|
||||||
|
added = add_finding(dismissed, manual)
|
||||||
|
assert added["summary"]["codes"] == {"D4": 1}
|
||||||
|
assert added["card_results"][0]["score_reviewed"] == pytest.approx(66.7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_instructor_metric_weight_comes_from_lesson_weights():
|
||||||
|
report = report_with([dds_metric("dds_primary", True)], [])
|
||||||
|
report["full_report"]["criteria"]["score_weights"] = {INSTRUCTOR_METRIC: 3.0}
|
||||||
|
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||||
|
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||||
|
author="teacher", at=AT)
|
||||||
|
|
||||||
|
updated = add_finding(report, manual)
|
||||||
|
|
||||||
|
assert updated["metrics"][-1]["weight"] == 3.0
|
||||||
|
assert updated["score_reviewed"] == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lesson_criteria_accept_instructor_metric_weight():
|
||||||
|
from app.domain.events import LessonCriteria
|
||||||
|
|
||||||
|
assert LessonCriteria(score_weights={INSTRUCTOR_METRIC: 2.0}).score_weights == {
|
||||||
|
INSTRUCTOR_METRIC: 2.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_manual_finding_with_same_key_is_added_once():
|
||||||
|
report = report_with([dds_metric("dds_primary", True)], [])
|
||||||
|
manual = Finding(code=ErrorCode.D4, source=FindingSource.INSTRUCTOR, summary="D4",
|
||||||
|
fact="отказ без причины", norm="причина обязательна", card=1,
|
||||||
|
author="teacher", at=AT, client_id=uuid4())
|
||||||
|
|
||||||
|
once = add_finding(report, manual)
|
||||||
|
twice = add_finding(once, manual)
|
||||||
|
|
||||||
|
assert len(twice["findings"]) == 1
|
||||||
|
assert twice["score_reviewed"] == once["score_reviewed"] == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_shows_score_after_review(monkeypatch):
|
||||||
|
from reportlab import platypus
|
||||||
|
|
||||||
|
from app.scoring.export import to_pdf
|
||||||
|
from tests.test_report_export import sample_report
|
||||||
|
|
||||||
|
texts: list[str] = []
|
||||||
|
|
||||||
|
class Recorded(platypus.Paragraph):
|
||||||
|
def __init__(self, text, *args, **kwargs):
|
||||||
|
texts.append(text)
|
||||||
|
super().__init__(text, *args, **kwargs)
|
||||||
|
|
||||||
|
# Абзацы перехватываются до вёрстки: извлечь текст из PDF нечем без poppler.
|
||||||
|
monkeypatch.setattr(platypus, "Paragraph", Recorded)
|
||||||
|
assert to_pdf(sample_report().model_copy(update={"score_reviewed": 91.5})).startswith(b"%PDF-")
|
||||||
|
|
||||||
|
assert "<b>После разбора отметок:</b> 91.5" in texts
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP: связка 112 → ДДС, идемпотентность, занятие из истории ──
|
||||||
|
|
||||||
|
def test_call_code_with_queue_card_is_refused_in_handoff(lesson):
|
||||||
|
browser, state, store = lesson
|
||||||
|
commits = len(store.commits)
|
||||||
|
state.score["full_report"]["exercise"] = "call"
|
||||||
|
|
||||||
|
refused = browser.post(f"/api/sessions/{state.session_id}/findings",
|
||||||
|
json={"code": "E1", "fact": "факт", "norm": "норма", "card": 1})
|
||||||
|
|
||||||
|
assert refused.status_code == 422
|
||||||
|
assert refused.json()["detail"] == "call_code_without_card"
|
||||||
|
assert len(store.commits) == commits
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_add_request_does_not_add_second_finding(lesson):
|
||||||
|
browser, state, store = lesson
|
||||||
|
body = {"code": "D5", "fact": "нет адресата", "norm": "назвать адресата", "card": 1,
|
||||||
|
"client_id": str(uuid4())}
|
||||||
|
|
||||||
|
first = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
|
||||||
|
commits = len(store.commits)
|
||||||
|
second = browser.post(f"/api/sessions/{state.session_id}/findings", json=body)
|
||||||
|
|
||||||
|
assert first.status_code == second.status_code == 200
|
||||||
|
assert len(second.json()["findings"]) == len(first.json()["findings"])
|
||||||
|
assert second.json()["score_reviewed"] == first.json()["score_reviewed"]
|
||||||
|
assert not [record for _, batch in store.commits[commits:] for record in batch], (
|
||||||
|
"повтор ничего не пишет")
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_lesson_is_reviewed_through_http(monkeypatch):
|
||||||
|
"""Занятия нет в памяти узла: решение идёт через commit_archived, ответ — по строке после записи."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from tests.test_report_export import sample_report
|
||||||
|
|
||||||
|
archived = sample_report().model_dump(mode="json")
|
||||||
|
archived["findings"] = [dds_finding(ErrorCode.D6).model_dump(mode="json")]
|
||||||
|
archived["metrics"] = [dds_metric("dds_progress", False).model_dump(mode="json")]
|
||||||
|
archived.update(score_final=0.0, overridden_by=None, override_comment=None, score_auto=0.0)
|
||||||
|
session_id = archived["session_id"]
|
||||||
|
session_row = SimpleNamespace(id=session_id, trainee_id=uuid4(), owner_login="dev")
|
||||||
|
score_row = SimpleNamespace(
|
||||||
|
session_id=session_id, score_auto=0.0, score_final=0.0,
|
||||||
|
report={"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
|
||||||
|
"findings": archived["findings"], "metrics": archived["metrics"],
|
||||||
|
"full_report": archived},
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
async def get(self, _model, _key):
|
||||||
|
return session_row
|
||||||
|
|
||||||
|
async def scalar(self, _statement):
|
||||||
|
return score_row
|
||||||
|
|
||||||
|
def add(self, row):
|
||||||
|
self.added.append(row)
|
||||||
|
|
||||||
|
async def flush(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
db = FakeDb()
|
||||||
|
|
||||||
|
async def fake_session():
|
||||||
|
yield db
|
||||||
|
|
||||||
|
monkeypatch.setattr(hub, "store", PostgresSessionStore(lambda: db))
|
||||||
|
monkeypatch.setattr(hub, "get", lambda _session_id: None)
|
||||||
|
monkeypatch.setitem(app.dependency_overrides, sessions.optional_session, fake_session)
|
||||||
|
with TestClient(app) as browser:
|
||||||
|
browser.post("/api/auth/dev-token")
|
||||||
|
hub.store = PostgresSessionStore(lambda: db)
|
||||||
|
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": "доклад по рации"})
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
body = response.json()
|
||||||
|
assert body["score_reviewed"] == 100.0 and body["score_final"] == 100.0
|
||||||
|
assert body["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
|
||||||
|
assert db.commits == 1, "решение и аудит — один commit"
|
||||||
|
assert score_row.score_final == 100.0
|
||||||
|
assert score_row.report["summary"]["codes"] == {}
|
||||||
|
audit = db.added[0]
|
||||||
|
assert audit.action == "finding.review" and audit.actor == "dev"
|
||||||
|
assert "D6 dismissed" in audit.detail and "доклад по рации" in audit.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_review_is_not_saved_when_audit_fails(postgres_access):
|
||||||
|
"""Fail-closed: строка аудита не записалась — решения и нового балла тоже нет."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import get_sessionmaker
|
||||||
|
from app.db.models import AuditLog, Score
|
||||||
|
from app.domain.events import Exercise, SessionMode
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.store import LessonRequest, ScoreCalculated
|
||||||
|
|
||||||
|
store = PostgresSessionStore(get_sessionmaker())
|
||||||
|
session_id = uuid4()
|
||||||
|
report = report_with([dds_metric("dds_progress", False)], [dds_finding(ErrorCode.D6)])
|
||||||
|
# actor в audit_log — String(80): длинный логин роняет INSERT аудита
|
||||||
|
# на стороне PostgreSQL, а Score меняется в той же транзакции.
|
||||||
|
broken = FindingReview(decision=FindingDecision.DISMISSED, reason="доклад по рации",
|
||||||
|
author="x" * 200, at=AT)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
def build(_identity):
|
||||||
|
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||||
|
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||||
|
exercise=Exercise.DDS)
|
||||||
|
|
||||||
|
await store.open(LessonRequest(
|
||||||
|
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||||
|
trainee_name=None, trainee_id=None, owner_login="teacher", backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
await store.commit_archived(session_id, [ScoreCalculated(50.0, report)])
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await store.commit_archived(session_id, [FindingReviewed(
|
||||||
|
index=0, role="instructor", review=broken,
|
||||||
|
)])
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||||
|
audit = await db.scalar(select(AuditLog).where(
|
||||||
|
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review",
|
||||||
|
))
|
||||||
|
return score, audit
|
||||||
|
|
||||||
|
score, audit = asyncio.run(scenario())
|
||||||
|
assert audit is None
|
||||||
|
assert score.score_final == 50.0
|
||||||
|
assert score.report["findings"][0].get("reviews", []) == []
|
||||||
|
assert "score_reviewed" not in score.report
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_lesson_review_through_http_in_postgres(postgres_access, monkeypatch):
|
||||||
|
"""Тот же маршрут на настоящей PostgreSQL: ответ перечитан после commit под блокировкой."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.base import get_sessionmaker
|
||||||
|
from app.db.models import AuditLog
|
||||||
|
from app.domain.events import Exercise, SessionMode
|
||||||
|
from app.session.pg_store import PostgresSessionStore
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.store import LessonRequest, ScoreCalculated
|
||||||
|
from tests.test_report_export import sample_report
|
||||||
|
|
||||||
|
session_id = uuid4()
|
||||||
|
archived = sample_report().model_dump(mode="json")
|
||||||
|
archived.update(session_id=str(session_id), score_final=0.0, score_auto=0.0,
|
||||||
|
overridden_by=None, override_comment=None,
|
||||||
|
findings=[dds_finding(ErrorCode.D6).model_dump(mode="json")],
|
||||||
|
metrics=[dds_metric("dds_progress", False).model_dump(mode="json")])
|
||||||
|
report = {"score_auto": 0.0, "summary": {"codes": {"D6": 1}},
|
||||||
|
"findings": archived["findings"], "metrics": archived["metrics"],
|
||||||
|
"full_report": archived}
|
||||||
|
store = PostgresSessionStore(get_sessionmaker())
|
||||||
|
|
||||||
|
async def prepare():
|
||||||
|
def build(_identity):
|
||||||
|
return SessionState(session_id=session_id, scenario_id="fire-apartment-l2",
|
||||||
|
scenario_title="Пожар", level="L2", mode=SessionMode.TRAINING,
|
||||||
|
exercise=Exercise.DDS)
|
||||||
|
|
||||||
|
await store.open(LessonRequest(
|
||||||
|
session_id=session_id, scenario_id="fire-apartment-l2", mode="training",
|
||||||
|
trainee_name=None, trainee_id=None, owner_login="dev", backend_node_id=None,
|
||||||
|
), build)
|
||||||
|
await store.commit_archived(session_id, [ScoreCalculated(0.0, report)])
|
||||||
|
|
||||||
|
async def audit():
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
return await db.scalar(select(AuditLog).where(
|
||||||
|
AuditLog.object_id == str(session_id), AuditLog.action == "finding.review"))
|
||||||
|
|
||||||
|
monkeypatch.setattr(hub, "get", lambda _session_id: None)
|
||||||
|
# Пул соединений привязан к циклу событий: подготовка и проверка идут
|
||||||
|
# в цикле того же TestClient, что и запрос.
|
||||||
|
with TestClient(app) as browser:
|
||||||
|
browser.post("/api/auth/dev-token")
|
||||||
|
previous, hub.store = hub.store, store
|
||||||
|
try:
|
||||||
|
browser.portal.call(prepare)
|
||||||
|
response = browser.post(f"/api/sessions/{session_id}/findings/0/review",
|
||||||
|
json={"decision": "dismissed", "reason": "доклад по рации"})
|
||||||
|
row = browser.portal.call(audit)
|
||||||
|
finally:
|
||||||
|
hub.store = previous
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["score_final"] == 100.0
|
||||||
|
assert response.json()["findings"][0]["reviews"][-1]["reason"] == "доклад по рации"
|
||||||
|
assert row is not None and "доклад по рации" in row.detail
|
||||||
|
|
@ -21,7 +21,7 @@ from app.session.hub import hub
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def client():
|
def client(template_caller):
|
||||||
# Доступность базы проверяется подключением к порту: синхронного драйвера
|
# Доступность базы проверяется подключением к порту: синхронного драйвера
|
||||||
# в проекте нет, и попытка проверить им даёт ложный пропуск теста.
|
# в проекте нет, и попытка проверить им даёт ложный пропуск теста.
|
||||||
import socket
|
import socket
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ def test_csv_contains_sections_and_blocks_formula_injection():
|
||||||
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
|
||||||
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
|
||||||
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
|
||||||
|
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
|
||||||
assert _cell(" =cmd") == "' =cmd"
|
assert _cell(" =cmd") == "' =cmd"
|
||||||
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
assert _cell("\tОбычный текст") == "'\tОбычный текст"
|
||||||
|
|
||||||
|
|
@ -89,6 +90,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
|
||||||
assert "Отчёт по учебному занятию" in extracted
|
assert "Отчёт по учебному занятию" in extracted
|
||||||
assert "Адрес должен быть уточнён" in extracted
|
assert "Адрес должен быть уточнён" in extracted
|
||||||
assert "Заявитель сообщает о дыме" in extracted
|
assert "Заявитель сообщает о дыме" in extracted
|
||||||
|
assert "Лимит реакции на доклад бригады" in extracted
|
||||||
|
|
||||||
|
|
||||||
def test_certificate_pdf_contains_saved_result(tmp_path):
|
def test_certificate_pdf_contains_saved_result(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ from pathlib import Path
|
||||||
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
||||||
|
|
||||||
# Public by design: credential entry/session bootstrap and the non-sensitive
|
# Public by design: credential entry/session bootstrap and the non-sensitive
|
||||||
# incident classification dictionary. dev-token has its own fail-closed flag
|
# incident classification dictionary and service catalog. dev-token has its own fail-closed flag
|
||||||
# and remains hidden in production.
|
# and remains hidden in production.
|
||||||
PUBLIC_HTTP_ROUTES = {
|
PUBLIC_HTTP_ROUTES = {
|
||||||
("auth.py", "post", "/login"),
|
("auth.py", "post", "/login"),
|
||||||
("auth.py", "post", "/dev-token"),
|
("auth.py", "post", "/dev-token"),
|
||||||
("http/ekp.py", "get", "/groups"),
|
("http/ekp.py", "get", "/groups"),
|
||||||
("http/ekp.py", "get", "/signs"),
|
("http/ekp.py", "get", "/signs"),
|
||||||
|
("http/ekp.py", "get", "/services"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Routes that centralize ownership + authentication checks in a shared helper.
|
# Routes that centralize ownership + authentication checks in a shared helper.
|
||||||
|
|
@ -83,6 +84,8 @@ HTTP_ROLE_GATE_POLICY = {
|
||||||
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
|
||||||
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
|
||||||
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
("http/sessions.py", "patch", "/{session_id}/report"): (("INSTRUCTOR",),),
|
||||||
|
("http/sessions.py", "post", "/{session_id}/findings/{index}/review"): (("INSTRUCTOR",),),
|
||||||
|
("http/sessions.py", "post", "/{session_id}/findings"): (("INSTRUCTOR",),),
|
||||||
("http/sessions.py", "get", ""): ((),),
|
("http/sessions.py", "get", ""): ((),),
|
||||||
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
("http/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
|
||||||
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
|
||||||
|
|
|
||||||
167
backend/tests/test_service_catalog.py
Normal file
167
backend/tests/test_service_catalog.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
|
||||||
|
|
||||||
|
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
|
||||||
|
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.api.ws import call as call_ws
|
||||||
|
from app.domain import ekp
|
||||||
|
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
|
||||||
|
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
|
||||||
|
from app.main import app
|
||||||
|
from app.scenarios.loader import load_file
|
||||||
|
from app.scoring.card import evaluate_card
|
||||||
|
from app.session.hub import hub
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_keeps_all_209_services_with_attribution_and_source():
|
||||||
|
data = ekp.catalog()
|
||||||
|
|
||||||
|
assert len(data.services) == 209
|
||||||
|
assert len({item.name for item in data.services}) == 209
|
||||||
|
assert sum(item.service_level == "ДДС" for item in data.services) == 157
|
||||||
|
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
|
||||||
|
assert data.license.startswith("MIT")
|
||||||
|
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
|
||||||
|
for item in data.services)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [
|
||||||
|
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
|
||||||
|
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
|
||||||
|
"ЦЭМП",
|
||||||
|
'ЦОДД (ГКУ "Центр организации дорожного движения")',
|
||||||
|
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
|
||||||
|
])
|
||||||
|
def test_rows_checked_against_our_screenshots_are_verbatim(name):
|
||||||
|
assert name in ekp.catalog_names()
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_services_and_notify_lists_are_untouched():
|
||||||
|
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
|
||||||
|
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
|
||||||
|
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
|
||||||
|
assert len(ekp.services()) == 61
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_adds_catalog_services_without_changing_the_notify_list():
|
||||||
|
assert "notify_extra" in EDITABLE_KIO_FIELDS
|
||||||
|
card = KIO(notify=["Служба 101"])
|
||||||
|
|
||||||
|
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
|
||||||
|
|
||||||
|
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
|
||||||
|
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
|
||||||
|
with pytest.raises(PatchRejected):
|
||||||
|
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_already_in_notify_is_not_added_twice():
|
||||||
|
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
|
||||||
|
card = KIO(notify=["ЦЭМП"])
|
||||||
|
|
||||||
|
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
|
||||||
|
|
||||||
|
assert updated.notify_extra == ["ФСБ"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_card_with_renamed_service_still_loads():
|
||||||
|
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
|
||||||
|
не должна ломать восстановление снимков и сданных карточек."""
|
||||||
|
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
|
||||||
|
|
||||||
|
assert card.notify_extra == ["Прежнее название службы"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_services_do_not_change_the_score_against_the_reference():
|
||||||
|
library = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
scenario = load_file(library / "fire-apartment-l2.yaml", library)
|
||||||
|
card = KIO(address="улица Ленина, 14", description="горит балкон")
|
||||||
|
|
||||||
|
plain = evaluate_card(scenario, card)
|
||||||
|
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
|
||||||
|
|
||||||
|
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
|
||||||
|
assert plain.score == extended.score
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_is_served_like_the_classifier():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/ekp/services")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["services"]) == 209
|
||||||
|
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lesson(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||||
|
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
|
||||||
|
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
|
||||||
|
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
|
||||||
|
level="L1", mode=SessionMode.TRAINING)
|
||||||
|
hub.register(state)
|
||||||
|
|
||||||
|
def patch(fields):
|
||||||
|
sent.clear()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
return list(sent)
|
||||||
|
|
||||||
|
return state, patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
|
||||||
|
state, patch = lesson
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["Служба, которой нет"]})
|
||||||
|
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
|
||||||
|
assert state.kio.notify_extra == []
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["ФСБ"]})
|
||||||
|
assert state.kio.notify_extra == ["ФСБ"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
|
||||||
|
"""Пакет отклоняется целиком — фронт должен получить серверные значения
|
||||||
|
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
|
||||||
|
state, patch = lesson
|
||||||
|
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
|
||||||
|
|
||||||
|
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
|
||||||
|
|
||||||
|
assert isinstance(sent[0], ErrorEvent)
|
||||||
|
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
|
||||||
|
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
|
||||||
|
assert state.kio.address == "улица Ленина, 14"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
|
||||||
|
state, patch = lesson
|
||||||
|
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
|
||||||
|
|
||||||
|
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
|
||||||
|
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
|
||||||
|
assert state.kio.notify_extra == ["ФСБ"]
|
||||||
|
|
@ -12,12 +12,13 @@ from app.domain.events import (
|
||||||
SessionMode,
|
SessionMode,
|
||||||
)
|
)
|
||||||
from app.domain.kio import KIO
|
from app.domain.kio import KIO
|
||||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
from app.domain.statuses import PhoneCallPending, PhoneReportRecord, ServiceStatus
|
||||||
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import ErrorCode, Finding, FindingSource
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.loader import load_file
|
from app.scenarios.loader import load_file
|
||||||
from app.session.checkpoint import dump_state, load_state
|
from app.session.checkpoint import dump_state, load_state
|
||||||
from app.session.dds import prepare_handoff_queue, prepare_queue
|
from app.session.dds import prepare_handoff_queue, prepare_queue
|
||||||
|
from app.session.finish import score_dds_card
|
||||||
from app.session.state import DdsCardRecord, SessionState, now_utc
|
from app.session.state import DdsCardRecord, SessionState, now_utc
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
|
@ -85,6 +86,38 @@ def test_active_dds_session_round_trips_without_losing_work():
|
||||||
assert timer.limit_ms == 45_000
|
assert timer.limit_ms == 45_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_reaction_keeps_times_and_limit_after_restore():
|
||||||
|
before = dds_state()
|
||||||
|
before.criteria.dds_report_reaction_limit_seconds = 20
|
||||||
|
card = before.desk.active
|
||||||
|
service = before.card_services(card)[0]
|
||||||
|
card.phone_reports.append(PhoneReportRecord(
|
||||||
|
service=service, crew=card.crew_selected, phase="dispatched",
|
||||||
|
text="Бригада выехала", at=now_utc(),
|
||||||
|
))
|
||||||
|
card.set_service_status(service, ServiceStatus.RESPONDING,
|
||||||
|
"Основание: доклад.\nСведения: бригада выехала.")
|
||||||
|
card.phone_reports[0].at -= timedelta(seconds=70)
|
||||||
|
|
||||||
|
restored = load_state(dump_state(before), now_utc())
|
||||||
|
|
||||||
|
assert restored.criteria.dds_report_reaction_limit_seconds == 20
|
||||||
|
assert restored.desk.active.phone_reports == card.phone_reports
|
||||||
|
|
||||||
|
def reaction(state):
|
||||||
|
result = score_dds_card(state, state.desk.active)
|
||||||
|
metric = next(item for item in result.metrics if item.key == "dds_report_reaction")
|
||||||
|
findings = [item for item in result.findings if item.metric_key == "dds_report_reaction"]
|
||||||
|
return metric, findings
|
||||||
|
|
||||||
|
metric, findings = reaction(restored)
|
||||||
|
assert not metric.passed
|
||||||
|
assert metric.norm == "отметить статус по докладу за 20 с"
|
||||||
|
assert "70 с (+50 с сверх норматива)" in metric.fact
|
||||||
|
assert [item.code for item in findings] == [ErrorCode.D6]
|
||||||
|
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
|
||||||
|
|
||||||
|
|
||||||
def completed_card() -> DdsCardRecord:
|
def completed_card() -> DdsCardRecord:
|
||||||
return DdsCardRecord(
|
return DdsCardRecord(
|
||||||
card_id=uuid4(),
|
card_id=uuid4(),
|
||||||
|
|
|
||||||
517
backend/tests/test_session_signals.py
Normal file
517
backend/tests/test_session_signals.py
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
"""Колонка сигналов реестра: очередь, повторные отказы, курсант не на связи."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.engine.result import IteratorResult, SimpleResultMetaData
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
from app.api.auth import Principal
|
||||||
|
from app.api.http import sessions as sessions_http
|
||||||
|
from app.api.ws import session as session_ws
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db.models import Session
|
||||||
|
from app.domain.events import Exercise, SessionMode
|
||||||
|
from app.domain.roles import Role
|
||||||
|
from app.main import app
|
||||||
|
from app.session.checkpoint import dump_state, load_state
|
||||||
|
from app.session import hub as hub_module
|
||||||
|
from app.session.hub import SessionHub, hub
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
POOL = ["fire-apartment-l2", "t01-1-fire-container"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(monkeypatch):
|
||||||
|
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
async def audit_override(*_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def optional_session_override():
|
||||||
|
yield None
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions_http, "audit_required", audit_override)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
app.dependency_overrides, sessions_http.optional_session, optional_session_override
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
test_client.post("/api/auth/dev-token")
|
||||||
|
hub.store = MemorySessionStore()
|
||||||
|
before = set(hub._sessions)
|
||||||
|
try:
|
||||||
|
yield test_client
|
||||||
|
finally:
|
||||||
|
for session_id in set(hub._sessions) - before:
|
||||||
|
hub.drop(session_id)
|
||||||
|
finally:
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(predicate, timeout=3):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < end:
|
||||||
|
value = predicate()
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
time.sleep(0.02)
|
||||||
|
raise AssertionError("состояние не обновилось")
|
||||||
|
|
||||||
|
|
||||||
|
def read_until(socket, wanted):
|
||||||
|
received = []
|
||||||
|
for _ in range(20):
|
||||||
|
event = socket.receive_json()
|
||||||
|
received.append(event["type"])
|
||||||
|
if event["type"] == wanted:
|
||||||
|
return event
|
||||||
|
raise AssertionError(f"событие {wanted} не пришло; получены: {received}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_two_card_dds(client, scenario_ids=POOL):
|
||||||
|
session_id = uuid4()
|
||||||
|
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||||
|
control = context.__enter__()
|
||||||
|
control.send_json({
|
||||||
|
"type": "scenario.start",
|
||||||
|
"scenario_id": POOL[0],
|
||||||
|
"trainee": "Иванов",
|
||||||
|
"mode": "training",
|
||||||
|
"exercise": "dds",
|
||||||
|
"random_scenario_ids": scenario_ids,
|
||||||
|
})
|
||||||
|
wait_for(lambda: hub.get(session_id))
|
||||||
|
return session_id, control
|
||||||
|
|
||||||
|
|
||||||
|
def start_card_handoff(client):
|
||||||
|
session_id = uuid4()
|
||||||
|
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||||
|
control = context.__enter__()
|
||||||
|
control.send_json({
|
||||||
|
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
|
||||||
|
"mode": "training", "exercise": "card", "handoff_to_dds": True,
|
||||||
|
"scenario_ids": POOL,
|
||||||
|
})
|
||||||
|
wait_for(lambda: hub.get(session_id))
|
||||||
|
return session_id, control
|
||||||
|
|
||||||
|
|
||||||
|
def start_call(client):
|
||||||
|
session_id = uuid4()
|
||||||
|
context = client.websocket_connect(f"/ws/control/{session_id}")
|
||||||
|
control = context.__enter__()
|
||||||
|
control.send_json({
|
||||||
|
"type": "scenario.start", "scenario_id": POOL[0], "trainee": "Иванов",
|
||||||
|
"mode": "training", "exercise": "call",
|
||||||
|
})
|
||||||
|
wait_for(lambda: hub.get(session_id))
|
||||||
|
return session_id, control
|
||||||
|
|
||||||
|
|
||||||
|
def row_for(client, session_id):
|
||||||
|
rows = client.get("/api/sessions/active").json()
|
||||||
|
return next(item for item in rows if item["session_id"] == str(session_id))
|
||||||
|
|
||||||
|
|
||||||
|
def signal_kinds(row):
|
||||||
|
return {signal["kind"] for signal in row["signals"]}
|
||||||
|
|
||||||
|
|
||||||
|
def assigned_trainee(state, monkeypatch):
|
||||||
|
state.trainee_id = uuid4()
|
||||||
|
who = Principal(login="курсант", full_name="Курсант", role=Role.TRAINEE,
|
||||||
|
trainee_id=state.trainee_id)
|
||||||
|
monkeypatch.setattr(session_ws, "principal_of", lambda _ws: who)
|
||||||
|
|
||||||
|
|
||||||
|
def registry_time(monkeypatch, since, seconds):
|
||||||
|
class Clock(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return since + timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions_http, "datetime", Clock)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_backlog_when_queue_reaches_threshold(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assert len(state.desk.cards) == 2, "оба билета должны прийти сразу без интервала"
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert row["dds_open_cards"] == 2
|
||||||
|
assert "backlog" in signal_kinds(row)
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_declined_card_leaves_backlog(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("SIGNAL_BACKLOG_THRESHOLD", "2")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
first_card = state.desk.ordered()[0]
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": state.card_services(first_card)[0],
|
||||||
|
"status": "declined", "comment": "не наш адрес, передано в УК",
|
||||||
|
})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert row["dds_open_cards"] == 2, "отклонённая карточка ждёт «Следующей» на пульте"
|
||||||
|
assert "backlog" not in signal_kinds(row)
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_refusals_after_two_consecutive_declines(client):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
first_card, second_card = state.desk.ordered()
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
first_service = state.card_services(first_card)[0]
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": first_service, "status": "declined",
|
||||||
|
"comment": "не наш адрес, передано в УК",
|
||||||
|
})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert "refusals" not in signal_kinds(row), "одного отказа недостаточно для сигнала"
|
||||||
|
|
||||||
|
station.send_json({"type": "card.open", "card_id": str(second_card.card_id)})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
second_service = state.card_services(second_card)[0]
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": second_service, "status": "declined",
|
||||||
|
"comment": "не наша территория, передано в ОМВД",
|
||||||
|
})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert state.consecutive_refusals == 2
|
||||||
|
assert "refusals" in signal_kinds(row)
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_clears_refusal_streak(client):
|
||||||
|
session_id, control = start_two_card_dds(
|
||||||
|
client, [*POOL, "t20-2-stroke"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
for card in state.desk.ordered()[:2]:
|
||||||
|
station.send_json({"type": "card.open", "card_id": str(card.card_id)})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": state.card_services(card)[0],
|
||||||
|
"status": "declined", "comment": "Не наша территория, передано дежурному",
|
||||||
|
})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert state.consecutive_refusals == 2
|
||||||
|
third = state.desk.ordered()[2]
|
||||||
|
station.send_json({"type": "card.open", "card_id": str(third.card_id)})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
station.send_json({
|
||||||
|
"type": "card.status", "service": state.card_services(third)[0],
|
||||||
|
"status": "accepted", "comment": "Карточка принята диспетчером",
|
||||||
|
})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert state.consecutive_refusals == 0
|
||||||
|
assert "refusals" not in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_offline_when_station_socket_is_closed_past_the_window(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert hub.station_connected(session_id)
|
||||||
|
assert not hub.station_connected(session_id)
|
||||||
|
disconnected_at = state.socket_last_seen_at
|
||||||
|
assert disconnected_at is not None
|
||||||
|
window = get_settings().signal_offline_window_seconds
|
||||||
|
registry_time(monkeypatch, disconnected_at, window - 1)
|
||||||
|
assert "offline" not in signal_kinds(row_for(client, session_id))
|
||||||
|
registry_time(monkeypatch, disconnected_at, window + 1)
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert "offline" in signal_kinds(row)
|
||||||
|
assert state.socket_last_seen_at == disconnected_at, "GET реестра не должен менять занятие"
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_offline_without_first_connection(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
started = state.socket_last_seen_at
|
||||||
|
assert started is not None
|
||||||
|
registry_time(monkeypatch, started, get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_socket_disconnection_triggers_offline(client, monkeypatch):
|
||||||
|
session_id, control = start_call(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as call:
|
||||||
|
read_until(call, "call.incoming")
|
||||||
|
assert hub.trainee_connected(session_id)
|
||||||
|
assert not hub.trainee_connected(session_id)
|
||||||
|
disconnected_at = state.socket_last_seen_at
|
||||||
|
registry_time(monkeypatch, disconnected_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_teacher_socket_does_not_hide_offline(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert not hub.station_connected(session_id)
|
||||||
|
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_of_two_trainee_sockets_starts_offline_window(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as first:
|
||||||
|
read_until(first, "station.state")
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as second:
|
||||||
|
read_until(second, "station.state")
|
||||||
|
assert hub.station_connected(session_id)
|
||||||
|
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" not in signal_kinds(row_for(client, session_id))
|
||||||
|
disconnected_at = state.socket_last_seen_at
|
||||||
|
registry_time(monkeypatch, disconnected_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_window_survives_checkpoint_restore(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
disconnected_at = state.socket_last_seen_at
|
||||||
|
restored = load_state(dump_state(state), datetime.now(UTC))
|
||||||
|
hub.drop(session_id)
|
||||||
|
asyncio.run(hub._adopt(restored))
|
||||||
|
assert restored.socket_last_seen_at == disconnected_at
|
||||||
|
registry_time(monkeypatch, disconnected_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
assert "offline" in signal_kinds(row_for(client, session_id))
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_takeover_starts_window_when_socket_was_open(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert state.socket_connected_at_checkpoint
|
||||||
|
snapshot = dump_state(state)
|
||||||
|
old_time = state.socket_last_seen_at
|
||||||
|
takeover_at = old_time + timedelta(minutes=10)
|
||||||
|
monkeypatch.setattr(hub_module, "now_utc", lambda: takeover_at)
|
||||||
|
restored = load_state(snapshot, datetime.now(UTC))
|
||||||
|
|
||||||
|
class CapturingStore(MemorySessionStore):
|
||||||
|
def __init__(self):
|
||||||
|
self.saved = None
|
||||||
|
|
||||||
|
async def commit(self, state, _records=()):
|
||||||
|
self.saved = dump_state(state)
|
||||||
|
|
||||||
|
store = CapturingStore()
|
||||||
|
local_hub = SessionHub(store)
|
||||||
|
try:
|
||||||
|
assert asyncio.run(local_hub._adopt(restored))
|
||||||
|
assert restored.socket_last_seen_at == takeover_at
|
||||||
|
assert not restored.socket_connected_at_checkpoint
|
||||||
|
assert datetime.fromisoformat(
|
||||||
|
store.saved["socket_last_seen_at"].replace("Z", "+00:00")
|
||||||
|
) == takeover_at
|
||||||
|
assert store.saved["socket_connected_at_checkpoint"] is False
|
||||||
|
finally:
|
||||||
|
local_hub.stop_ticker(session_id)
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_card_handoff_uses_trainee_station_presence(client, monkeypatch):
|
||||||
|
session_id, control = start_card_handoff(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
assigned_trainee(state, monkeypatch)
|
||||||
|
with monkeypatch.context() as clock:
|
||||||
|
with client.websocket_connect(f"/ws/call/{session_id}") as call:
|
||||||
|
read_until(call, "card.briefing")
|
||||||
|
call.send_json({"type": "card.submit"})
|
||||||
|
read_until(call, "call.ended")
|
||||||
|
handoff_at = state.socket_last_seen_at
|
||||||
|
clock.setattr(hub_module, "now_utc", lambda: handoff_at + timedelta(minutes=1))
|
||||||
|
assert state.socket_last_seen_at == handoff_at, "старый call-сокет не сбрасывает окно ДДС"
|
||||||
|
assert state.exercise.value == "card" and state.dds_phase
|
||||||
|
assert state.desk.scenarios
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
read_until(station, "station.state")
|
||||||
|
registry_time(monkeypatch, state.socket_last_seen_at,
|
||||||
|
get_settings().signal_offline_window_seconds + 1)
|
||||||
|
row = row_for(client, session_id)
|
||||||
|
assert row["dds_open_cards"] > 0
|
||||||
|
assert "offline" not in signal_kinds(row)
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkpoint_on_another_node_marks_presence_unknown(client, monkeypatch):
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
state = hub.get(session_id)
|
||||||
|
snapshot = dump_state(state)
|
||||||
|
checkpoint_at = datetime.now(UTC)
|
||||||
|
hub.drop(session_id)
|
||||||
|
|
||||||
|
row = Session(id=session_id, owner_login="dev",
|
||||||
|
live_state=snapshot, checkpoint_at=checkpoint_at)
|
||||||
|
|
||||||
|
class CheckpointDB:
|
||||||
|
async def scalars(self, _query):
|
||||||
|
return IteratorResult(SimpleResultMetaData(["Session"]), iter([(row,)])).scalars()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sessions_http, "require", lambda _request, _role:
|
||||||
|
Principal(login="dev", full_name="Преподаватель",
|
||||||
|
role=Role.INSTRUCTOR))
|
||||||
|
rows = asyncio.run(sessions_http.active(None, db=CheckpointDB()))
|
||||||
|
row = next(item for item in rows if item.session_id == session_id)
|
||||||
|
assert row.presence_known is False
|
||||||
|
assert "offline" not in {item.kind for item in row.signals}
|
||||||
|
finally:
|
||||||
|
hub.stop_ticker(session_id)
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_presence_commit_does_not_leave_connected_socket():
|
||||||
|
class BrokenStore(MemorySessionStore):
|
||||||
|
async def commit(self, _state, _records=()):
|
||||||
|
raise OSError("test commit failure")
|
||||||
|
|
||||||
|
local_hub = SessionHub(BrokenStore())
|
||||||
|
state = local_hub.register(SessionState(
|
||||||
|
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
|
||||||
|
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
))
|
||||||
|
|
||||||
|
async def connect():
|
||||||
|
async with local_hub.trainee_socket(state.session_id, station=True, trainee=True):
|
||||||
|
assert local_hub.is_lease_fenced(state.session_id)
|
||||||
|
|
||||||
|
asyncio.run(connect())
|
||||||
|
assert not local_hub.station_connected(state.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_presence_commit_closes_station_with_fencing_code(client, monkeypatch):
|
||||||
|
class BrokenStore(MemorySessionStore):
|
||||||
|
async def commit(self, _state, _records=()):
|
||||||
|
raise OSError("test commit failure")
|
||||||
|
|
||||||
|
session_id, control = start_two_card_dds(client)
|
||||||
|
try:
|
||||||
|
assigned_trainee(hub.get(session_id), monkeypatch)
|
||||||
|
hub.store = BrokenStore()
|
||||||
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
|
event = station.receive_json()
|
||||||
|
assert event["type"] == "error" and event["code"] == "internal"
|
||||||
|
with pytest.raises(WebSocketDisconnect) as closed:
|
||||||
|
station.receive_json()
|
||||||
|
assert closed.value.code == 1012
|
||||||
|
assert not hub.station_connected(session_id)
|
||||||
|
finally:
|
||||||
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_takeover_retries_failed_presence_checkpoint():
|
||||||
|
class FlakyStore(MemorySessionStore):
|
||||||
|
def __init__(self):
|
||||||
|
self.commits = 0
|
||||||
|
|
||||||
|
async def commit(self, _state, _records=()):
|
||||||
|
self.commits += 1
|
||||||
|
if self.commits == 1:
|
||||||
|
raise OSError("temporary database outage")
|
||||||
|
|
||||||
|
store = FlakyStore()
|
||||||
|
local_hub = SessionHub(store)
|
||||||
|
state = SessionState(
|
||||||
|
session_id=uuid4(), scenario_id="test", scenario_title="Тест", level="L1",
|
||||||
|
mode=SessionMode.TRAINING, exercise=Exercise.DDS,
|
||||||
|
socket_last_seen_at=datetime.now(UTC) - timedelta(minutes=10),
|
||||||
|
socket_connected_at_checkpoint=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recover():
|
||||||
|
assert not await local_hub._adopt(state)
|
||||||
|
assert local_hub.get(state.session_id) is None
|
||||||
|
await local_hub.maintain_lease()
|
||||||
|
assert local_hub.get(state.session_id) is state
|
||||||
|
assert store.commits == 2
|
||||||
|
await local_hub.shutdown()
|
||||||
|
|
||||||
|
asyncio.run(recover())
|
||||||
|
|
@ -22,7 +22,7 @@ from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(monkeypatch):
|
def client(monkeypatch, template_caller):
|
||||||
async def audit_in_memory(*_args, **_kwargs):
|
async def audit_in_memory(*_args, **_kwargs):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
|
||||||
}, source: "operator" });
|
}, source: "operator" });
|
||||||
socket = reconnect(socket);
|
socket = reconnect(socket);
|
||||||
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
||||||
|
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
|
||||||
|
call.send(refused);
|
||||||
|
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
|
||||||
|
socket.sent = [];
|
||||||
|
call.send({ type: "kio.patch", fields: { floor: "2" } });
|
||||||
|
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
|
||||||
|
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
|
||||||
|
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
|
||||||
|
socket = reconnect(socket);
|
||||||
|
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
|
||||||
|
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
|
||||||
|
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
|
||||||
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
||||||
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
||||||
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
<article className="debrief-stat">
|
<article className="debrief-stat">
|
||||||
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
|
<span>Автоматическая оценка</span><strong>{report.score_auto.toFixed(0)}<small> из 100</small></strong>
|
||||||
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
|
<small>{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}</small>
|
||||||
|
{report.score_reviewed != null && <small>после разбора отметок: {report.score_reviewed.toFixed(0)}</small>}
|
||||||
</article>
|
</article>
|
||||||
<article className="debrief-stat">
|
<article className="debrief-stat">
|
||||||
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
|
<span>Упражнение</span><strong>{report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}</strong>
|
||||||
|
|
@ -80,6 +81,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
||||||
{report.exercise === "card" && <>Норматив заполнения КИО —
|
{report.exercise === "card" && <>Норматив заполнения КИО —
|
||||||
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
||||||
|
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
|
||||||
|
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
|
||||||
|
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
|
||||||
{report.exercise && report.exercise !== "dds" && <>
|
{report.exercise && report.exercise !== "dds" && <>
|
||||||
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
|
||||||
</>}.
|
</>}.
|
||||||
|
|
@ -92,7 +96,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
|
{Boolean(report.card_results?.length) && <section className="debrief-card-results">
|
||||||
<h3>Карточки занятия ({report.card_results!.length})</h3>
|
<h3>Карточки занятия ({report.card_results!.length})</h3>
|
||||||
{report.card_results!.map((card, index) => <details key={card.card_id}>
|
{report.card_results!.map((card, index) => <details key={card.card_id}>
|
||||||
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
|
<summary>Карточка {index + 1}: {card.title || card.scenario_id} · {(card.score_reviewed ?? card.score_auto).toFixed(0)} / 100
|
||||||
|
{card.score_reviewed != null && ` после разбора (автооценка ${card.score_auto.toFixed(0)})`}
|
||||||
{card.address ? ` · ${card.address}` : ""}</summary>
|
{card.address ? ` · ${card.address}` : ""}</summary>
|
||||||
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
|
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
|
||||||
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
|
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
|
||||||
|
|
@ -147,6 +152,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
<td>
|
<td>
|
||||||
{finding.summary}
|
{finding.summary}
|
||||||
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
||||||
|
{finding.source === "instructor" && <div className="ref">отметка преподавателя {finding.author ?? ""}</div>}
|
||||||
|
{(finding.reviews ?? []).map((review, reviewIndex) => <div className="ref" key={reviewIndex}>
|
||||||
|
{review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
|
||||||
|
</div>)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
162
frontend/src/features/debrief/FindingReview.tsx
Normal file
162
frontend/src/features/debrief/FindingReview.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||||
|
//
|
||||||
|
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
|
||||||
|
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
const DDS_CODES: ErrorCode[] = ["D1", "D2", "D3", "D4", "D5", "D6"];
|
||||||
|
const CALL_CODES: ErrorCode[] = ["E1", "E2", "E3", "E4", "E5", "E6"];
|
||||||
|
|
||||||
|
async function send(url: string, body: unknown): Promise<SessionReport> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = data && typeof data === "object" && "detail" in data ? data.detail : response.statusText;
|
||||||
|
throw new Error(typeof detail === "string" ? detail : "Не удалось сохранить решение.");
|
||||||
|
}
|
||||||
|
return data as SessionReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindingReview({ report, onChange }: {
|
||||||
|
report: SessionReport;
|
||||||
|
onChange: (report: SessionReport) => void;
|
||||||
|
}) {
|
||||||
|
const cards = report.card_results ?? [];
|
||||||
|
const codes = report.exercise === "dds" ? DDS_CODES
|
||||||
|
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
|
||||||
|
const [reasons, setReasons] = useState<Record<number, string>>({});
|
||||||
|
const [code, setCode] = useState<ErrorCode>(codes[0]);
|
||||||
|
// В ДДС все метрики принадлежат карточкам очереди. В связке 112 → ДДС
|
||||||
|
// E-коды относятся к занятию, а D-код без карточки — к живой карточке звонка.
|
||||||
|
const [card, setCard] = useState(report.exercise === "dds" && cards.length ? "1" : "");
|
||||||
|
const cardChoice = cards.length > 0 && (report.exercise === "dds" || code.startsWith("D"));
|
||||||
|
// Один ключ на отметку, пока она не сохранена: повтор после сбоя сети
|
||||||
|
// не добавит её второй раз.
|
||||||
|
const pendingId = useRef<string | null>(null);
|
||||||
|
const [fact, setFact] = useState("");
|
||||||
|
const [norm, setNorm] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const base = `/api/sessions/${report.session_id}/findings`;
|
||||||
|
|
||||||
|
async function run(action: () => Promise<SessionReport>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
onChange(await action());
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "Не удалось сохранить решение.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decide(index: number, decision: FindingDecision) {
|
||||||
|
const reason = (reasons[index] ?? "").trim();
|
||||||
|
if (!reason) {
|
||||||
|
setError("Укажите причину решения по отметке.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await send(`${base}/${index}/review`, { decision, reason });
|
||||||
|
setReasons((current) => ({ ...current, [index]: "" }));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFinding() {
|
||||||
|
if (!fact.trim() || !norm.trim()) {
|
||||||
|
setError("Для отметки нужны факт и норма.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingId.current ??= crypto.randomUUID();
|
||||||
|
const clientId = pendingId.current;
|
||||||
|
void run(async () => {
|
||||||
|
const updated = await send(base, {
|
||||||
|
code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
|
||||||
|
client_id: clientId,
|
||||||
|
});
|
||||||
|
pendingId.current = null;
|
||||||
|
setFact("");
|
||||||
|
setNorm("");
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="debrief finding-review">
|
||||||
|
<h2>Разбор по отметкам</h2>
|
||||||
|
<p className="ref">
|
||||||
|
Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается
|
||||||
|
по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом.
|
||||||
|
{report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`}
|
||||||
|
</p>
|
||||||
|
<table className="grid">
|
||||||
|
<tbody>
|
||||||
|
{report.findings.map((finding, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<th>{finding.code}</th>
|
||||||
|
<td>
|
||||||
|
{finding.summary}
|
||||||
|
<div className="ref">{finding.fact}{finding.norm && ` · ${finding.norm}`}</div>
|
||||||
|
{(finding.reviews ?? []).map((review, reviewIndex, all) => <div key={reviewIndex}
|
||||||
|
className={reviewIndex < all.length - 1 || review.decision === "dismissed" ? "ref" : "state-violated"}>
|
||||||
|
{review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
|
||||||
|
{new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
|
||||||
|
</div>)}
|
||||||
|
<div className="finding-review-actions">
|
||||||
|
<input aria-label={`Причина решения по отметке ${index + 1}`} maxLength={1000}
|
||||||
|
placeholder="Причина решения" value={reasons[index] ?? ""}
|
||||||
|
onChange={(event) => setReasons((current) => ({ ...current, [index]: event.target.value }))} />
|
||||||
|
<button type="button" disabled={busy} onClick={() => decide(index, "confirmed")}>Подтвердить</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => decide(index, "dismissed")}>Снять</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{report.findings.length === 0 && <tr><td colSpan={2}>Автоматических отметок нет.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Добавить отметку</h3>
|
||||||
|
<div className="finding-review-form">
|
||||||
|
<label>Код
|
||||||
|
<select value={code} onChange={(event) => {
|
||||||
|
pendingId.current = null;
|
||||||
|
setCode(event.target.value as ErrorCode);
|
||||||
|
}}>
|
||||||
|
{codes.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{cardChoice && <label>Карточка
|
||||||
|
<select value={card} onChange={(event) => { pendingId.current = null; setCard(event.target.value); }}>
|
||||||
|
{report.exercise !== "dds" && <option value="">занятие (без карточки)</option>}
|
||||||
|
{cards.map((item, index) => <option key={item.card_id} value={String(index + 1)}>
|
||||||
|
{index + 1}: {item.title || item.scenario_id}
|
||||||
|
</option>)}
|
||||||
|
</select>
|
||||||
|
</label>}
|
||||||
|
<label>Факт
|
||||||
|
<input maxLength={1000} value={fact} onChange={(event) => { pendingId.current = null; setFact(event.target.value); }}
|
||||||
|
placeholder="Что сделал курсант" />
|
||||||
|
</label>
|
||||||
|
<label>Норма
|
||||||
|
<input maxLength={1000} value={norm} onChange={(event) => { pendingId.current = null; setNorm(event.target.value); }}
|
||||||
|
placeholder="Как требовалось" />
|
||||||
|
</label>
|
||||||
|
<button type="button" disabled={busy || !fact.trim() || !norm.trim()} onClick={addFinding}>
|
||||||
|
{busy ? "Сохраняем…" : "Добавить отметку"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="violated" role="alert">{error}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
|
||||||
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
||||||
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
||||||
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
||||||
|
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||||
|
|
@ -39,6 +40,10 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||||
"required_fields", "description_grammar", "card_fill_time",
|
"required_fields", "description_grammar", "card_fill_time",
|
||||||
].includes(key));
|
].includes(key));
|
||||||
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
|
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
|
||||||
|
// Метрика появляется, только когда ручной отметке преподавателя нечего провалить.
|
||||||
|
const REVIEW_METRICS: MetricOption[] = [
|
||||||
|
{ key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." },
|
||||||
|
];
|
||||||
|
|
||||||
export function ScoreWeightsEditor({
|
export function ScoreWeightsEditor({
|
||||||
exercise,
|
exercise,
|
||||||
|
|
@ -57,6 +62,7 @@ export function ScoreWeightsEditor({
|
||||||
: exercise === "card_dds"
|
: exercise === "card_dds"
|
||||||
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
|
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
|
||||||
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
|
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
|
||||||
|
groups.push(["Разбор", REVIEW_METRICS]);
|
||||||
|
|
||||||
function setWeight(key: string, raw: string) {
|
function setWeight(key: string, raw: string) {
|
||||||
const next = { ...values };
|
const next = { ...values };
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
|
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
|
||||||
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
|
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
|
||||||
|
|
||||||
import { useIncidentGroups, useSigns } from "@/shared/api/http";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
|
||||||
import { type CardState, display } from "./merge";
|
import { type CardState, display } from "./merge";
|
||||||
import { type Field, type FieldGroup, visibleGroups } from "./fields";
|
import { type Field, type FieldGroup, visibleGroups } from "./fields";
|
||||||
|
|
||||||
|
|
@ -17,6 +19,7 @@ interface Props {
|
||||||
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
|
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
|
||||||
const dds = display(state, "dds") as string | null;
|
const dds = display(state, "dds") as string | null;
|
||||||
const incidentGroup = display(state, "incident_group") as number | null;
|
const incidentGroup = display(state, "incident_group") as number | null;
|
||||||
|
const notify = (display(state, "notify") as string[] | null) ?? [];
|
||||||
const groups = visibleGroups(dds);
|
const groups = visibleGroups(dds);
|
||||||
const section = (group: FieldGroup) => (
|
const section = (group: FieldGroup) => (
|
||||||
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
||||||
|
|
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
||||||
readOnly={readOnly || field.readOnly}
|
readOnly={readOnly || field.readOnly}
|
||||||
referenceArm={referenceArm}
|
referenceArm={referenceArm}
|
||||||
incidentGroup={incidentGroup}
|
incidentGroup={incidentGroup}
|
||||||
|
notify={notify}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
|
field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
|
||||||
}: {
|
}: {
|
||||||
field: Field;
|
field: Field;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
|
@ -67,6 +71,7 @@ function Row({
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
referenceArm: boolean;
|
referenceArm: boolean;
|
||||||
incidentGroup: number | null;
|
incidentGroup: number | null;
|
||||||
|
notify: string[];
|
||||||
onChange?: (path: string, value: unknown) => void;
|
onChange?: (path: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
||||||
|
|
@ -85,7 +90,7 @@ function Row({
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
|
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
|
||||||
) : (
|
) : (
|
||||||
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
|
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,12 +98,13 @@ function Row({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Input({
|
function Input({
|
||||||
field, value, referenceArm, incidentGroup, onChange,
|
field, value, referenceArm, incidentGroup, notify, onChange,
|
||||||
}: {
|
}: {
|
||||||
field: Field;
|
field: Field;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
referenceArm: boolean;
|
referenceArm: boolean;
|
||||||
incidentGroup: number | null;
|
incidentGroup: number | null;
|
||||||
|
notify: string[];
|
||||||
onChange?: (path: string, value: unknown) => void;
|
onChange?: (path: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const emit = (next: unknown) => onChange?.(field.path, next);
|
const emit = (next: unknown) => onChange?.(field.path, next);
|
||||||
|
|
@ -141,6 +147,9 @@ function Input({
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (field.kind === "services") {
|
||||||
|
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
|
||||||
|
}
|
||||||
if (field.kind === "signs") {
|
if (field.kind === "signs") {
|
||||||
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
|
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +179,46 @@ function Input({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Как `max_length` у `KIO.notify_extra`. */
|
||||||
|
const SERVICES_LIMIT = 20;
|
||||||
|
|
||||||
|
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
|
||||||
|
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
|
||||||
|
function ServicesPicker({ value, notify, onChange }: {
|
||||||
|
value: string[]; notify: string[]; onChange: (next: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const catalog = useServiceCatalog();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
// Лимит и службы из списка оповещения — те же правила, что на сервере:
|
||||||
|
// лишняя служба отклонила бы весь пакет правок.
|
||||||
|
const full = value.length >= SERVICES_LIMIT;
|
||||||
|
const found = needle && !full
|
||||||
|
? (catalog.data?.services ?? [])
|
||||||
|
.filter((item) => !value.includes(item.name) && !notify.includes(item.name)
|
||||||
|
&& item.name.toLowerCase().includes(needle))
|
||||||
|
.slice(0, 8)
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<div className="kio-services">
|
||||||
|
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
|
||||||
|
{value.map((name) => <span key={name}>
|
||||||
|
{name} <button type="button" aria-label={`Убрать ${name}`}
|
||||||
|
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
|
||||||
|
</span>)}
|
||||||
|
</div>}
|
||||||
|
<input type="search" aria-label="Поиск службы в каталоге"
|
||||||
|
placeholder={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
|
||||||
|
value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
{found.length > 0 && <ul className="kio-services-found">
|
||||||
|
{found.map((item) => <li key={item.id}>
|
||||||
|
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
|
||||||
|
</li>)}
|
||||||
|
</ul>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
|
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
|
||||||
const page = useIncidentGroups();
|
const page = useIncidentGroups();
|
||||||
return <select aria-label="Категория происшествия" value={value ?? ""}
|
return <select aria-label="Категория происшествия" value={value ?? ""}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import type { DDSCode, IncidentType } from "@/shared/types/generated";
|
import type { DDSCode, IncidentType } from "@/shared/types/generated";
|
||||||
|
|
||||||
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
|
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
|
||||||
|
|
||||||
export interface Field {
|
export interface Field {
|
||||||
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
|
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
|
||||||
|
|
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
|
||||||
fields: [
|
fields: [
|
||||||
// Список оповещения оператор не выбирает: он производен от признаков.
|
// Список оповещения оператор не выбирает: он производен от признаков.
|
||||||
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
|
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
|
||||||
|
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
|
||||||
|
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
|
||||||
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
|
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
|
||||||
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
|
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
|
||||||
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
|
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
|
||||||
const value = readPath(card.server, path);
|
const value = readPath(card.server, path);
|
||||||
return value === null || value === undefined || value === "" ? "—" : String(value);
|
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||||||
}
|
}
|
||||||
|
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
|
||||||
|
function names(card: CardState, path: string): string[] {
|
||||||
|
const value = readPath(card.server, path);
|
||||||
|
return Array.isArray(value) ? value.map(String) : [];
|
||||||
|
}
|
||||||
function time(value: string): string {
|
function time(value: string): string {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
||||||
|
|
@ -386,7 +391,8 @@ export function Dds() {
|
||||||
{Boolean(report.card_results?.length) && <>
|
{Boolean(report.card_results?.length) && <>
|
||||||
<h3>Результаты по карточкам</h3>
|
<h3>Результаты по карточкам</h3>
|
||||||
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
|
{report.card_results?.map((item, index) => <details className="dds-details" key={item.card_id}>
|
||||||
<summary>Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100</summary>
|
<summary>Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
|
||||||
|
{item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}</summary>
|
||||||
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
|
<p><b>Ответ диспетчера:</b> {item.reply_text || "не внесён"}</p>
|
||||||
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
|
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
|
||||||
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
|
<p key={metric.key} className={metric.passed ? "ref" : "state-violated"}>
|
||||||
|
|
@ -556,6 +562,7 @@ export function Dds() {
|
||||||
<div><b>{field(card, "description")}</b></div>
|
<div><b>{field(card, "description")}</b></div>
|
||||||
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
||||||
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
|
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
|
||||||
|
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
|
||||||
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dds-case-stage">
|
<div className="dds-case-stage">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { Debrief } from "@/features/debrief/Debrief";
|
import { Debrief } from "@/features/debrief/Debrief";
|
||||||
|
import { FindingReview } from "@/features/debrief/FindingReview";
|
||||||
import { Director } from "@/features/instructor/Director";
|
import { Director } from "@/features/instructor/Director";
|
||||||
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
|
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
|
||||||
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
|
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
|
||||||
|
|
@ -22,6 +23,7 @@ import {
|
||||||
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
|
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
|
||||||
useScenarios, useSessions, useTrainees,
|
useScenarios, useSessions, useTrainees,
|
||||||
} from "@/shared/api/http";
|
} from "@/shared/api/http";
|
||||||
|
import type { Signal } from "@/shared/api/http";
|
||||||
import { sessionIdFromUrl } from "@/shared/api/session";
|
import { sessionIdFromUrl } from "@/shared/api/session";
|
||||||
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
|
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated";
|
||||||
|
|
||||||
|
|
@ -44,6 +46,12 @@ function modeLabel(mode: SessionMode): string {
|
||||||
return MODES.find((item) => item.value === mode)?.label ?? mode;
|
return MODES.find((item) => item.value === mode)?.label ?? mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function worstSignalSeverity(signals: Signal[]): "warn" | "violated" | null {
|
||||||
|
return signals.some((signal) => signal.severity === "violated")
|
||||||
|
? "violated"
|
||||||
|
: signals.length ? "warn" : null;
|
||||||
|
}
|
||||||
|
|
||||||
function sessionDateLabel(value: string | null): string | null {
|
function sessionDateLabel(value: string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
|
|
@ -85,6 +93,7 @@ export function Instructor() {
|
||||||
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
|
||||||
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
|
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
|
||||||
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
|
||||||
|
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
|
||||||
const [allowedErrors, setAllowedErrors] = useState(0);
|
const [allowedErrors, setAllowedErrors] = useState(0);
|
||||||
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
|
||||||
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
|
||||||
|
|
@ -299,6 +308,7 @@ export function Instructor() {
|
||||||
decision_time_limit_seconds: decisionTimeLimit,
|
decision_time_limit_seconds: decisionTimeLimit,
|
||||||
card_fill_time_limit_seconds: cardFillTimeLimit,
|
card_fill_time_limit_seconds: cardFillTimeLimit,
|
||||||
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
|
||||||
|
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
|
||||||
allowed_errors: allowedErrors,
|
allowed_errors: allowedErrors,
|
||||||
require_correct_grammar: requireCorrectGrammar,
|
require_correct_grammar: requireCorrectGrammar,
|
||||||
score_weights: lessonScoreWeights,
|
score_weights: lessonScoreWeights,
|
||||||
|
|
@ -306,7 +316,7 @@ export function Instructor() {
|
||||||
});
|
});
|
||||||
if (sent) toStart.current = null;
|
if (sent) toStart.current = null;
|
||||||
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
|
||||||
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
|
||||||
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
|
||||||
randomScenario, visibleScenarios]);
|
randomScenario, visibleScenarios]);
|
||||||
|
|
||||||
|
|
@ -338,7 +348,7 @@ export function Instructor() {
|
||||||
Список ваших активных сессий обновляется автоматически каждые 2 секунды.
|
Список ваших активных сессий обновляется автоматически каждые 2 секунды.
|
||||||
</p>
|
</p>
|
||||||
<div className="dds-table-wrap"><table className="grid">
|
<div className="dds-table-wrap"><table className="grid">
|
||||||
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th></th></tr></thead>
|
<thead><tr><th>Курсант</th><th>Упражнение · карточка</th><th>Ход</th><th>ДДС</th><th>Задержки</th><th>Сигналы</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(activeSessions.data ?? []).map((item) => (
|
{(activeSessions.data ?? []).map((item) => (
|
||||||
<tr key={item.session_id}>
|
<tr key={item.session_id}>
|
||||||
|
|
@ -383,12 +393,18 @@ export function Instructor() {
|
||||||
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
|
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
|
||||||
: "Нормативы не нарушены"}
|
: "Нормативы не нарушены"}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
|
||||||
|
{item.signals.length
|
||||||
|
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
|
||||||
|
: item.presence_known ? "Сигналов нет" : null}
|
||||||
|
{!item.presence_known && <span>Связь курсанта не проверена</span>}
|
||||||
|
</td>
|
||||||
<td><button type="button" onClick={() => openActiveSession(item)}>
|
<td><button type="button" onClick={() => openActiveSession(item)}>
|
||||||
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
|
{sessionId === item.session_id ? "Открыто" : "Наблюдать"}
|
||||||
</button></td>
|
</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!activeSessions.data?.length && <tr><td colSpan={6}>
|
{!activeSessions.data?.length && <tr><td colSpan={7}>
|
||||||
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
|
{activeSessions.isLoading ? "Загружаем активные занятия…" : "Активных занятий пока нет."}
|
||||||
</td></tr>}
|
</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -648,6 +664,13 @@ export function Instructor() {
|
||||||
onCommit={setDdsCardWorkTimeLimit} />
|
onCommit={setDdsCardWorkTimeLimit} />
|
||||||
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
||||||
</label>}
|
</label>}
|
||||||
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
|
Реакция на доклад бригады, секунд
|
||||||
|
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
|
||||||
|
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
|
||||||
|
onCommit={setDdsReportReactionLimit} />
|
||||||
|
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
|
||||||
|
</label>}
|
||||||
<label className="lesson-criterion">
|
<label className="lesson-criterion">
|
||||||
Допустимо нарушенных метрик
|
Допустимо нарушенных метрик
|
||||||
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
|
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
|
||||||
|
|
@ -809,6 +832,8 @@ export function Instructor() {
|
||||||
{session.report && <>
|
{session.report && <>
|
||||||
<Debrief report={overrideReport?.session_id === session.report.session_id
|
<Debrief report={overrideReport?.session_id === session.report.session_id
|
||||||
? overrideReport : session.report} />
|
? overrideReport : session.report} />
|
||||||
|
<FindingReview report={overrideReport?.session_id === session.report.session_id
|
||||||
|
? overrideReport : session.report} onChange={setOverrideReport} />
|
||||||
<section className="debrief debrief-score-override">
|
<section className="debrief debrief-score-override">
|
||||||
<h2>Ручная корректировка оценки</h2>
|
<h2>Ручная корректировка оценки</h2>
|
||||||
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>
|
<p className="ref">Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.</p>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { Level, SessionMode, SessionReport, StationSnapshot } from "@/shared/types/generated";
|
import type {
|
||||||
|
Level, SessionMode, SessionReport, StationSnapshot, TimerState,
|
||||||
|
} from "@/shared/types/generated";
|
||||||
|
|
||||||
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
|
import { reportErrorMessage, reportRetryDelay, shouldRetryReport } from "./report.mjs";
|
||||||
|
|
||||||
|
|
@ -94,6 +96,12 @@ export interface SessionInfo {
|
||||||
end_reason: string | null;
|
end_reason: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Signal {
|
||||||
|
kind: "backlog" | "refusals" | "offline";
|
||||||
|
severity: TimerState;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ActiveSessionInfo {
|
export interface ActiveSessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
trainee_name: string | null;
|
trainee_name: string | null;
|
||||||
|
|
@ -109,6 +117,8 @@ export interface ActiveSessionInfo {
|
||||||
dds_work_overdue_cards: number;
|
dds_work_overdue_cards: number;
|
||||||
dds_statuses: Record<string, string>;
|
dds_statuses: Record<string, string>;
|
||||||
dds_snapshot: StationSnapshot | null;
|
dds_snapshot: StationSnapshot | null;
|
||||||
|
signals: Signal[];
|
||||||
|
presence_known: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useActiveSessions = () =>
|
export const useActiveSessions = () =>
|
||||||
|
|
@ -230,6 +240,17 @@ export const useIncidentGroups = () => useQuery({
|
||||||
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
|
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface ServiceCatalogPage {
|
||||||
|
source: string;
|
||||||
|
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
|
||||||
|
export const useServiceCatalog = () => useQuery({
|
||||||
|
queryKey: ["ekp-services"], staleTime: Infinity,
|
||||||
|
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
|
||||||
|
});
|
||||||
|
|
||||||
export function useSigns(prefix: string[], group: number | null = null) {
|
export function useSigns(prefix: string[], group: number | null = null) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (group !== null) params.set("group", String(group));
|
if (group !== null) params.set("group", String(group));
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,17 @@ export const callChannel = (
|
||||||
...options,
|
...options,
|
||||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||||
isAcknowledged: (incoming, pending) => {
|
isAcknowledged: (incoming, pending) => {
|
||||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
|
||||||
|| incoming.source !== "operator") return false;
|
// `auto` по всем полям команды с другим значением — сервер её переписал
|
||||||
|
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
|
||||||
|
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
|
||||||
|
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
|
||||||
|
// могла не дойти, повтор безвреден.
|
||||||
|
if (incoming.source === "auto") {
|
||||||
|
const keys = Object.keys(pending.fields);
|
||||||
|
return keys.every((key) => key in incoming.fields)
|
||||||
|
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
|
||||||
|
}
|
||||||
return Object.entries(pending.fields).every(([key, value]) =>
|
return Object.entries(pending.fields).every(([key, value]) =>
|
||||||
sameJsonValue(value, incoming.fields[key]),
|
sameJsonValue(value, incoming.fields[key]),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ export interface DdsCardReport {
|
||||||
card_id: string;
|
card_id: string;
|
||||||
scenario_id: string;
|
scenario_id: string;
|
||||||
score_auto: number;
|
score_auto: number;
|
||||||
|
score_reviewed?: number | null;
|
||||||
reply_text: string;
|
reply_text: string;
|
||||||
metrics: Array<Metric>;
|
metrics: Array<Metric>;
|
||||||
findings: Array<Finding>;
|
findings: Array<Finding>;
|
||||||
|
|
@ -267,6 +268,23 @@ export interface Finding {
|
||||||
competency?: Competency | null;
|
competency?: Competency | null;
|
||||||
transcript_ref?: string | null;
|
transcript_ref?: string | null;
|
||||||
at?: string | null;
|
at?: string | null;
|
||||||
|
metric_key?: string | null;
|
||||||
|
card?: number | null;
|
||||||
|
service?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
client_id?: string | null;
|
||||||
|
reviews?: Array<FindingReview>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Решение преподавателя по отметке на разборе. */
|
||||||
|
export type FindingDecision = "confirmed" | "dismissed";
|
||||||
|
|
||||||
|
/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
|
||||||
|
export interface FindingReview {
|
||||||
|
decision: FindingDecision;
|
||||||
|
reason: string;
|
||||||
|
author: string;
|
||||||
|
at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
|
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
|
||||||
|
|
@ -346,6 +364,8 @@ export interface KIO {
|
||||||
evacuation_needed?: boolean;
|
evacuation_needed?: boolean;
|
||||||
/** Список оповещения */
|
/** Список оповещения */
|
||||||
notify?: Array<string>;
|
notify?: Array<string>;
|
||||||
|
/** Дополнительно оповестить */
|
||||||
|
notify_extra?: Array<string>;
|
||||||
/** Устаревшее: одна служба. Заменено списком оповещения */
|
/** Устаревшее: одна служба. Заменено списком оповещения */
|
||||||
dds?: DDSCode | null;
|
dds?: DDSCode | null;
|
||||||
dispatch_order_at?: string | null;
|
dispatch_order_at?: string | null;
|
||||||
|
|
@ -380,6 +400,7 @@ export interface LessonCriteria {
|
||||||
decision_time_limit_seconds?: number;
|
decision_time_limit_seconds?: number;
|
||||||
card_fill_time_limit_seconds?: number;
|
card_fill_time_limit_seconds?: number;
|
||||||
dds_card_work_time_limit_seconds?: number;
|
dds_card_work_time_limit_seconds?: number;
|
||||||
|
dds_report_reaction_limit_seconds?: number;
|
||||||
allowed_errors?: number;
|
allowed_errors?: number;
|
||||||
require_correct_grammar?: boolean;
|
require_correct_grammar?: boolean;
|
||||||
score_weights?: Record<string, number>;
|
score_weights?: Record<string, number>;
|
||||||
|
|
@ -407,6 +428,10 @@ export interface Metric {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
credit?: number | null;
|
credit?: number | null;
|
||||||
|
card?: number | null;
|
||||||
|
service?: string | null;
|
||||||
|
passed_auto?: boolean | null;
|
||||||
|
credit_auto?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModeSet {
|
export interface ModeSet {
|
||||||
|
|
@ -619,6 +644,7 @@ export interface SessionReport {
|
||||||
self_assessment_diff?: SelfAssessmentDiff | null;
|
self_assessment_diff?: SelfAssessmentDiff | null;
|
||||||
notes?: Array<InstructorNoteShown>;
|
notes?: Array<InstructorNoteShown>;
|
||||||
score_auto: number;
|
score_auto: number;
|
||||||
|
score_reviewed?: number | null;
|
||||||
score_final: number;
|
score_final: number;
|
||||||
overridden_by?: string | null;
|
overridden_by?: string | null;
|
||||||
override_comment?: string | null;
|
override_comment?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,9 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
|
.debrief-stat > strong { font-size: 24px; line-height: 1.15; overflow-wrap: anywhere; }
|
||||||
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
|
.debrief-stat > strong small { font-size: 14px; font-weight: 500; }
|
||||||
.debrief-stat > small { color: #5a6068; font-size: 12px; }
|
.debrief-stat > small { color: #5a6068; font-size: 12px; }
|
||||||
|
.finding-review-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.finding-review-actions input { flex: 1 1 200px; min-width: 0; }
|
||||||
|
.finding-review-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; align-items: end; }
|
||||||
.debrief-stat.is-passed { border-left-color: var(--ok); }
|
.debrief-stat.is-passed { border-left-color: var(--ok); }
|
||||||
.debrief-stat.is-failed { border-left-color: var(--violated); }
|
.debrief-stat.is-failed { border-left-color: var(--violated); }
|
||||||
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
|
.debrief-next-step { margin: 12px 0 18px; padding: 12px 15px; border: 1px solid var(--line); border-left: 4px solid #6b7780; border-radius: 5px; background: #fff; }
|
||||||
|
|
@ -785,6 +788,11 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
|
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
|
||||||
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||||
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
|
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
|
||||||
|
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||||
|
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
|
||||||
|
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
|
||||||
|
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
|
||||||
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
|
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
|
||||||
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
|
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
|
||||||
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }
|
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }
|
||||||
|
|
|
||||||
|
|
@ -174,31 +174,62 @@ def ws_connect(ws_base: str, path: str, cookie: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_fence(socket, timeout: float = 10) -> dict:
|
# Текст `_close_auth_state_unavailable` в backend/app/api/auth.py: 1013 с ним —
|
||||||
|
# закрытие от устаревшего кэша поколений, а не произвольный 1013.
|
||||||
|
AUTH_UNAVAILABLE_REASON = "Состояние доступа временно недоступно"
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_auth_fresh(base: str, cookie: str, timeout: float = 10) -> bool:
|
||||||
|
"""Узел принял cookie: кэш поколений свежий и логин ему известен."""
|
||||||
|
request = urllib.request.Request(f"{base}/api/auth/me", headers={"Cookie": cookie})
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=2) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
return False
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_fence(socket, timeout: float = 10, ack_id: str | None = None) -> dict:
|
||||||
|
acked = False
|
||||||
|
|
||||||
|
def outcome(**closure) -> dict:
|
||||||
|
if ack_id is not None:
|
||||||
|
closure["command_acked"] = acked
|
||||||
|
return closure
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
try:
|
try:
|
||||||
message = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
|
message = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
return {"closed": False, "fence_notice": False, "reason": "timeout"}
|
return outcome(closed=False, fence_notice=False, reason="timeout")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
|
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
|
||||||
close_code = getattr(close, "code", None) or getattr(exc, "code", None)
|
close_code = getattr(close, "code", None) or getattr(exc, "code", None)
|
||||||
return {
|
return outcome(
|
||||||
"closed": True,
|
closed=True,
|
||||||
"fence_notice": close_code == 1012,
|
fence_notice=close_code == 1012,
|
||||||
"close_code": close_code,
|
close_code=close_code,
|
||||||
"reason": getattr(close, "reason", "") if close else str(exc),
|
reason=getattr(close, "reason", "") if close else str(exc),
|
||||||
}
|
)
|
||||||
if isinstance(message, str):
|
if isinstance(message, str):
|
||||||
try:
|
try:
|
||||||
event = json.loads(message)
|
event = json.loads(message)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
if ack_id is not None and event.get("type") == "command.ack" \
|
||||||
|
and event.get("command_id") == ack_id:
|
||||||
|
acked = True
|
||||||
if event.get("message") == "Занятие передано другому backend-узлу; переподключитесь.":
|
if event.get("message") == "Занятие передано другому backend-узлу; переподключитесь.":
|
||||||
return {"closed": True, "fence_notice": True, "close_code": 1012,
|
return outcome(closed=True, fence_notice=True, close_code=1012,
|
||||||
"reason": event.get("message")}
|
reason=event.get("message"))
|
||||||
return {"closed": False, "fence_notice": False, "reason": "timeout"}
|
return outcome(closed=False, fence_notice=False, reason="timeout")
|
||||||
|
|
||||||
|
|
||||||
async def main(args: argparse.Namespace) -> int:
|
async def main(args: argparse.Namespace) -> int:
|
||||||
|
|
@ -250,6 +281,13 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
instructor_cookie = login(backend, instructor_login, instructor_password)
|
instructor_cookie = login(backend, instructor_login, instructor_password)
|
||||||
trainee_cookie = login(backend, trainee_login, trainee_password)
|
trainee_cookie = login(backend, trainee_login, trainee_password)
|
||||||
|
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
|
||||||
|
# может отдать узлу B. B узнаёт новый логин при очередной сверке
|
||||||
|
# поколений (раз в секунду); до неё middleware считает cookie отозванной.
|
||||||
|
for port in (args.backend_port, args.backend_b_port):
|
||||||
|
for cookie in (instructor_cookie, trainee_cookie):
|
||||||
|
if not await wait_auth_fresh(f"http://127.0.0.1:{port}", cookie):
|
||||||
|
raise RuntimeError(f"backend :{port} не узнал новый логин за 10 с")
|
||||||
await send_control(ws_base, session_id, instructor_cookie, {
|
await send_control(ws_base, session_id, instructor_cookie, {
|
||||||
"type": "scenario.start", "scenario_id": args.scenario,
|
"type": "scenario.start", "scenario_id": args.scenario,
|
||||||
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
||||||
|
|
@ -400,14 +438,37 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
)
|
)
|
||||||
compose(args, "stop", proxy_name)
|
compose(args, "stop", proxy_name)
|
||||||
fault_stopped = True
|
fault_stopped = True
|
||||||
result["boundary_command_sent_to_old_owner"] = "arrived"
|
# Реальная мутация старому владельцу уже без БД: commit не пройдёт,
|
||||||
|
# ACK не придёт, а id не должен попасть в checkpoint нового
|
||||||
|
# владельца — прямое доказательство запрета записи после потери БД.
|
||||||
|
# Отправка сразу, до `compose ps`: через 2 с auth закроет сокет.
|
||||||
|
boundary_command_id = str(uuid4())
|
||||||
|
boundary = {"command_id": boundary_command_id, "status": "working"}
|
||||||
|
try:
|
||||||
|
await active_sockets["station"].send(json.dumps({
|
||||||
|
"type": "card.status", "service": service, "status": "working",
|
||||||
|
"comment": (
|
||||||
|
"Основание: доклад по карточке.\n"
|
||||||
|
f"Сведения: начало работ после обрыва БД; {service} notified."
|
||||||
|
),
|
||||||
|
"_command_id": boundary_command_id,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
boundary["sent"] = True
|
||||||
|
except websockets.exceptions.WebSocketException as exc:
|
||||||
|
boundary["sent"] = False
|
||||||
|
boundary["error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
result["boundary_command_sent_to_old_owner"] = boundary
|
||||||
|
result["checks"]["boundary_command_sent_after_partition"] = boundary["sent"]
|
||||||
running_services = compose(args, "ps", "--status", "running", "--services").splitlines()
|
running_services = compose(args, "ps", "--status", "running", "--services").splitlines()
|
||||||
result["checks"]["owner_process_running"] = owner_service in running_services
|
result["checks"]["owner_process_running"] = owner_service in running_services
|
||||||
if owner_service not in running_services:
|
if owner_service not in running_services:
|
||||||
raise RuntimeError("owner process did not remain running during DB partition")
|
raise RuntimeError("owner process did not remain running during DB partition")
|
||||||
closures = await asyncio.gather(*(
|
closures = await asyncio.gather(*(
|
||||||
wait_for_fence(socket, timeout=args.takeover_timeout)
|
wait_for_fence(
|
||||||
for socket in active_sockets.values()
|
socket, timeout=args.takeover_timeout,
|
||||||
|
ack_id=boundary_command_id if channel == "station" else None,
|
||||||
|
)
|
||||||
|
for channel, socket in active_sockets.items()
|
||||||
))
|
))
|
||||||
result["existing_channel_closures"] = dict(zip(active_sockets, closures))
|
result["existing_channel_closures"] = dict(zip(active_sockets, closures))
|
||||||
result["existing_channel_fence_notices"] = {
|
result["existing_channel_fence_notices"] = {
|
||||||
|
|
@ -418,10 +479,27 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
f"existing_{channel}_closed": closure["closed"]
|
f"existing_{channel}_closed": closure["closed"]
|
||||||
for channel, closure in result["existing_channel_closures"].items()
|
for channel, closure in result["existing_channel_closures"].items()
|
||||||
})
|
})
|
||||||
|
# Без БД старый владелец закрывает каналы fail-closed одним из двух
|
||||||
|
# путей: auth не может сверить поколения учёток дольше 2 с — 1013,
|
||||||
|
# supervisor lease замечает потерю владения (цикл 5 с) — 1012.
|
||||||
|
# Auth обычно успевает первым; оба кода корректны, фронт их не
|
||||||
|
# различает. Фактический код остаётся в existing_channel_closures.
|
||||||
|
result["existing_channel_close_codes"] = {
|
||||||
|
channel: closure.get("close_code")
|
||||||
|
for channel, closure in result["existing_channel_closures"].items()
|
||||||
|
}
|
||||||
result["checks"].update({
|
result["checks"].update({
|
||||||
f"existing_{channel}_fenced_with_1012": closure["fence_notice"]
|
f"existing_{channel}_closed_fail_closed": (
|
||||||
|
closure["fence_notice"] or (
|
||||||
|
closure.get("close_code") == 1013
|
||||||
|
and closure.get("reason") == AUTH_UNAVAILABLE_REASON
|
||||||
|
)
|
||||||
|
)
|
||||||
for channel, closure in result["existing_channel_closures"].items()
|
for channel, closure in result["existing_channel_closures"].items()
|
||||||
})
|
})
|
||||||
|
result["checks"]["boundary_command_not_acked"] = (
|
||||||
|
result["existing_channel_closures"]["station"].get("command_acked") is False
|
||||||
|
)
|
||||||
|
|
||||||
deadline = time.monotonic() + args.takeover_timeout
|
deadline = time.monotonic() + args.takeover_timeout
|
||||||
takeover = None
|
takeover = None
|
||||||
|
|
@ -457,12 +535,24 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result["checks"]["old_owner_process_healthy"] = False
|
result["checks"]["old_owner_process_healthy"] = False
|
||||||
result["old_owner_health_error"] = f"{type(exc).__name__}: {exc}"
|
result["old_owner_health_error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
# Auth на старом узле снова свежий: 1013 до accept исключён, и 403
|
||||||
|
# на рукопожатии ниже остаётся только за fencing занятия (1012).
|
||||||
|
result["checks"]["old_owner_auth_fresh"] = await wait_auth_fresh(
|
||||||
|
old_backend, instructor_cookie,
|
||||||
|
)
|
||||||
stale = await probe_ws(
|
stale = await probe_ws(
|
||||||
old_backend.replace("http://", "ws://"),
|
old_backend.replace("http://", "ws://"),
|
||||||
f"/ws/control/{session_id}", instructor_cookie,
|
f"/ws/control/{session_id}", instructor_cookie,
|
||||||
)
|
)
|
||||||
result["stale_owner_control"] = stale
|
result["stale_owner_control"] = stale
|
||||||
result["checks"]["stale_owner_control_rejected"] = not stale["connected"]
|
result["checks"]["stale_owner_control_rejected"] = (
|
||||||
|
not stale["connected"] and "HTTP 403" in stale.get("error", "")
|
||||||
|
)
|
||||||
|
# БД вернулась к старому владельцу, но его команда после обрыва так
|
||||||
|
# и не должна появиться в checkpoint: fencing epoch отверг запись.
|
||||||
|
result["checks"]["boundary_command_absent_from_checkpoint"] = (
|
||||||
|
not await wait_for_command_checkpoint(boundary_command_id, timeout=1)
|
||||||
|
)
|
||||||
|
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{args.frontend_url}/api/sessions/{session_id}",
|
f"{args.frontend_url}/api/sessions/{session_id}",
|
||||||
|
|
@ -524,6 +614,9 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
result["checks"]["boundary_command_reconciled"] = recovered_status in {
|
result["checks"]["boundary_command_reconciled"] = recovered_status in {
|
||||||
"responding", "arrived",
|
"responding", "arrived",
|
||||||
}
|
}
|
||||||
|
if args.failure_mode == "partition":
|
||||||
|
# `working` после обрыва не применён: статус остался на lost-ack `arrived`.
|
||||||
|
result["checks"]["boundary_command_not_applied"] = recovered_status == "arrived"
|
||||||
result["dds_progress_after_takeover"] = []
|
result["dds_progress_after_takeover"] = []
|
||||||
if recovered_status == "responding":
|
if recovered_status == "responding":
|
||||||
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie, {
|
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue