From f98c464f4b49da9088470e99a3c9a4623d98b9f6 Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 16:13:21 +0300
Subject: [PATCH 01/41] =?UTF-8?q?lct-35:=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE?=
=?UTF-8?q?=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF=D0=BE?=
=?UTF-8?q?=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D0=B5=D1=82,?=
=?UTF-8?q?=20=D1=81=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=20=D0=B8=20=D0=B4?=
=?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BE=D1=82?=
=?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20D1=E2=80=93D6=20=D1=81=20?=
=?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=D0=BE=D0=BC=20?=
=?UTF-8?q?=D0=B1=D0=B0=D0=BB=D0=BB=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 147 ++++++++-
backend/app/domain/events.py | 8 +
backend/app/domain/taxonomy.py | 25 ++
backend/app/scoring/card.py | 1 +
backend/app/scoring/dispatcher.py | 30 +-
backend/app/scoring/export.py | 25 +-
backend/app/scoring/gost.py | 2 +
backend/app/scoring/report.py | 1 +
backend/app/scoring/review.py | 147 +++++++++
backend/app/scoring/timing.py | 1 +
backend/app/session/finish.py | 20 +-
backend/app/session/pg_store.py | 27 ++
backend/app/session/score.py | 1 +
backend/app/session/store.py | 31 +-
backend/tests/test_finding_review.py | 298 ++++++++++++++++++
backend/tests/test_route_auth_contract.py | 2 +
frontend/src/features/debrief/Debrief.tsx | 5 +
.../src/features/debrief/FindingReview.tsx | 146 +++++++++
frontend/src/pages/instructor/Instructor.tsx | 3 +
frontend/src/shared/types/generated.ts | 21 ++
frontend/src/styles.css | 3 +
21 files changed, 923 insertions(+), 21 deletions(-)
create mode 100644 backend/app/scoring/review.py
create mode 100644 backend/tests/test_finding_review.py
create mode 100644 frontend/src/features/debrief/FindingReview.tsx
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index c22ddb6..a1122b3 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -24,15 +24,31 @@ from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
+from app.domain.taxonomy import (
+ ERRORS,
+ ErrorCode,
+ Finding,
+ FindingDecision,
+ FindingReview,
+ FindingSource,
+)
from app.domain.timers import TimerCode
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
+from app.scoring.taxonomy import METRIC_MAP
from app.session.access import can_access
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.score import scoring_scenario
from app.session.hub import hub
-from app.session.store import ScoreOverridden, apply_score_override
+from app.session.state import now_utc
+from app.session.store import (
+ FindingAdded,
+ FindingReviewed,
+ ScoreOverridden,
+ apply_finding_change,
+ apply_score_override,
+)
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -584,6 +600,133 @@ async def override(
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)
+
+ _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):
+ 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")
+ change = make(who, dict(score.report))
+ report = apply_finding_change(dict(score.report), change)
+ await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
+ await hub.store.commit_archived(session_id, [change])
+ return SessionReport.model_validate(report["full_report"])
+
+
+@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 = ""
+ 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,
+ ))
+
+ return await _change_findings(session_id, who, db, make)
+
+
@router.get("", response_model=list[SessionOut])
async def listing(
request: Request,
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 495708e..3cd3790 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -692,6 +692,12 @@ class Metric(BaseModel):
passed: bool
weight: float = 1.0
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):
@@ -780,6 +786,8 @@ class SessionReport(BaseModel):
self_assessment_diff: SelfAssessmentDiff | None = None
notes: list[InstructorNoteShown] = []
score_auto: float
+ #: Балл после решений преподавателя по отметкам; None — решений не было.
+ score_reviewed: float | None = None
score_final: float
overridden_by: str | None = None
override_comment: str | None = None
diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py
index 3de1537..b1365b2 100644
--- a/backend/app/domain/taxonomy.py
+++ b/backend/app/domain/taxonomy.py
@@ -152,6 +152,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):
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
@@ -164,3 +180,12 @@ class Finding(BaseModel):
competency: Competency | None = None
transcript_ref: str | 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
+ review: FindingReview | None = None
diff --git a/backend/app/scoring/card.py b/backend/app/scoring/card.py
index 7bc761a..37ce36b 100644
--- a/backend/app/scoring/card.py
+++ b/backend/app/scoring/card.py
@@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
summary=f"{title}: {fact}; требуется {norm}",
fact=fact, norm=norm, ref=ref, competency=competency,
+ metric_key=key,
))
if truth.incident_code:
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 0be2530..24e031a 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -28,8 +28,10 @@ from app.domain.timers import GOST_REF
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
-def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
+def _finding(service: str, code: ErrorCode, summary: str, fact: str,
+ norm: str | None = None) -> Finding:
return Finding(
+ service=service,
code=code,
source=FindingSource.DISPATCHER,
summary=summary,
@@ -117,7 +119,7 @@ def evaluate_dispatcher(
]
if missing_comment_statuses:
findings.append(_finding(
- ErrorCode.D5,
+ service, ErrorCode.D5,
f"{service}: к статусу не добавлены основание и сведения",
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
@@ -132,7 +134,7 @@ def evaluate_dispatcher(
]
if incomplete_comment_statuses:
findings.append(_finding(
- ErrorCode.D5,
+ service, ErrorCode.D5,
f"{service}: комментарии к статусам не разделяют основание и сведения",
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
@@ -152,7 +154,7 @@ def evaluate_dispatcher(
]
if missing_recipients:
findings.append(_finding(
- ErrorCode.D5,
+ service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
@@ -166,7 +168,7 @@ def evaluate_dispatcher(
if primary is None:
findings.append(
_finding(
- ErrorCode.D1,
+ service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
@@ -181,7 +183,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
- ErrorCode.D1,
+ service, ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
@@ -197,7 +199,7 @@ def evaluate_dispatcher(
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
findings.append(
_finding(
- ErrorCode.D4,
+ service, ErrorCode.D4,
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
fact="причина отказа не указана",
norm="комментарий к отказу обязателен",
@@ -209,7 +211,7 @@ def evaluate_dispatcher(
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
findings.append(
_finding(
- ErrorCode.D3,
+ service, ErrorCode.D3,
f"{service}: отказ от происшествия, которое в её компетенции",
fact="служба есть в списке оповещения по ЕКП",
norm="принять согласно эталону сценария",
@@ -218,7 +220,7 @@ def evaluate_dispatcher(
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: принято вопреки эталону сценария",
fact="карточка принята службой",
norm=expected_decision_reason or "отказать с указанной причиной",
@@ -232,7 +234,7 @@ def evaluate_dispatcher(
if service not in crew_assignments:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@@ -241,7 +243,7 @@ def evaluate_dispatcher(
else:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
@@ -252,7 +254,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments:
findings.append(
_finding(
- ErrorCode.D2,
+ service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@@ -268,7 +270,7 @@ def evaluate_dispatcher(
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
findings.append(
_finding(
- ErrorCode.D6,
+ service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
norm=(
@@ -301,7 +303,7 @@ def dispatcher_metrics(
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
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», статусы реагирования",
passed=passed, weight=weight,
))
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 6452c0a..9ee879b 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -11,6 +11,7 @@ from html import escape
from pathlib import Path
from app.domain.events import SessionReport
+from app.domain.taxonomy import Finding, FindingDecision, FindingSource
def _cell(value: object) -> str:
@@ -24,6 +25,21 @@ def _cell(value: object) -> str:
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:
+ """Решение по отметке рядом с ней: снятая отметка видна вместе с причиной."""
+ if item.review is None:
+ return ""
+ verdict = ("снята" if item.review.decision is FindingDecision.DISMISSED
+ else "подтверждена")
+ return f"; {verdict} преподавателем {item.review.author}: {item.review.reason}"
+
+
def to_csv(report: SessionReport) -> bytes:
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
output = io.StringIO(newline="")
@@ -44,6 +60,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
row("Оценка", "", "Автоматическая", report.score_auto)
+ row("Оценка", "", "После разбора отметок", report.score_reviewed)
row("Оценка", "", "Итоговая", report.score_final)
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
row("Оценка", "", "Причина изменения", report.override_comment)
@@ -54,7 +71,9 @@ def to_csv(report: SessionReport) -> bytes:
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
f"вес: {item.weight:g}{credit}; источник: {item.ref or ''}")
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):
row("Компетенции", number, item.competency, item.value)
for number, item in enumerate(report.transcript, 1):
@@ -183,7 +202,9 @@ def to_pdf(report: SessionReport) -> bytes:
story.append(p("Ошибок не выявлено", muted))
for item in report.findings:
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("Компетенции")
for item in report.competencies:
diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py
index b1bcce6..3ed2ac9 100644
--- a/backend/app/scoring/gost.py
+++ b/backend/app/scoring/gost.py
@@ -73,6 +73,7 @@ class _Builder:
norm=norm,
ref=ref,
competency=competency,
+ metric_key=key,
)
)
@@ -174,6 +175,7 @@ def evaluate(
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
ref="чек-лист сценария",
competency=Competency.INTERVIEW,
+ metric_key="checklist_completeness",
)
)
diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py
index 3f7a9aa..b55098d 100644
--- a/backend/app/scoring/report.py
+++ b/backend/app/scoring/report.py
@@ -106,6 +106,7 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
self_assessment_diff=difference,
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
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)),
overridden_by=score.get("overridden_by"),
override_comment=score.get("override_comment"),
diff --git a/backend/app/scoring/review.py b/backend/app/scoring/review.py
new file mode 100644
index 0000000..1a2691f
--- /dev/null
+++ b/backend/app/scoring/review.py
@@ -0,0 +1,147 @@
+"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
+
+Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
+действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
+Связь «отметка → метрика» — методика `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 METRIC_MAP
+
+#: Метрика для ручной отметки, которой нечего провалить: у кода нет метрики
+#: в METRIC_MAP (D4) или в карточке она не считалась. Вес — как у метрики по
+#: умолчанию, чтобы отметка преподавателя не весила больше автоматической.
+INSTRUCTOR_METRIC = "instructor_finding"
+
+
+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]) -> 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,
+ card=finding.card,
+ service=finding.service,
+ ))
+ return result
+
+
+def _rescore(report: dict, findings: list[Finding]) -> dict:
+ metrics = reviewed_metrics(
+ [Metric.model_validate(item) for item in report.get("metrics", [])], findings,
+ )
+ 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}
+ 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,
+ "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={"review": review})
+ return _rescore(report, findings)
+
+
+def add_finding(report: dict, finding: Finding) -> dict:
+ """Добавить отметку преподавателя; она штрафует связанную метрику."""
+ if finding.source is not FindingSource.INSTRUCTOR:
+ raise ValueError("вручную добавляется только отметка преподавателя")
+ return _rescore(report, [*_findings(report), finding])
diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py
index 31b2a8a..5b9deb1 100644
--- a/backend/app/scoring/timing.py
+++ b/backend/app/scoring/timing.py
@@ -95,4 +95,5 @@ def time_metric(
norm=norm_text,
ref=TIME_REF,
competency=Competency.NORMS,
+ metric_key=norm.key,
)
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 7d7f64a..124e4f6 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -26,10 +26,13 @@ from app.session.hub import hub
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import (
+ FindingAdded,
+ FindingReviewed,
LessonEnded,
ScoreArchived,
ScoreCalculated,
ScoreOverridden,
+ apply_finding_change,
apply_score_override,
)
@@ -100,9 +103,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
card_id=card.card_id,
scenario_id=card.scenario.id,
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],
- 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],
actions=actions,
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
@@ -234,3 +239,14 @@ def override_score(state, override: ScoreOverridden) -> None:
hub.record(state.session_id, override)
state.score = apply_score_override(state.score, override)
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))
diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py
index b35e33c..4416c54 100644
--- a/backend/app/session/pg_store.py
+++ b/backend/app/session/pg_store.py
@@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
from app.session.store import (
+ FindingAdded,
+ FindingReviewed,
HintRecorded,
LessonEnded,
LessonIdentity,
@@ -42,6 +44,7 @@ from app.session.store import (
SelfAssessed,
SessionLeaseLost,
UtteranceAppended,
+ apply_finding_change,
apply_score_override,
)
@@ -239,6 +242,30 @@ class PostgresSessionStore:
detail=(f"{score.score_auto} → {record.score_final}; "
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}")
+ before = score.score_final
+ 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):
+ actor, action = record.review.author, "finding.review"
+ what = (f"#{record.index} {record.review.decision.value}; "
+ f"reason_chars={len(record.review.reason)}")
+ else:
+ actor, action = record.finding.author or "instructor", "finding.add"
+ what = f"{record.finding.code.value}; fact_chars={len(record.finding.fact)}"
+ # Как у правки итога: текст решения живёт в разборе, в аудите —
+ # что изменено, кем и как сдвинулся балл.
+ 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()
@staticmethod
diff --git a/backend/app/session/score.py b/backend/app/session/score.py
index 10c637c..00e69a4 100644
--- a/backend/app/session/score.py
+++ b/backend/app/session/score.py
@@ -210,6 +210,7 @@ async def score_session(
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
+ metric_key="description_grammar",
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
diff --git a/backend/app/session/store.py b/backend/app/session/store.py
index ea053b1..0cea9ee 100644
--- a/backend/app/session/store.py
+++ b/backend/app/session/store.py
@@ -16,6 +16,8 @@ from typing import Protocol
from uuid import UUID
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.state import SessionState
@@ -83,8 +85,31 @@ class ScoreOverridden:
comment: str
+@dataclass(frozen=True)
+class FindingReviewed:
+ """Преподаватель подтвердил или снял отметку разбора."""
+ index: int
+ review: FindingReview
+ role: str
+
+
+@dataclass(frozen=True)
+class FindingAdded:
+ """Преподаватель добавил свою отметку (`source=instructor`)."""
+ finding: Finding
+ role: str
+
+
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
- | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
+ | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
+ | 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:
@@ -200,6 +225,10 @@ class MemorySessionStore:
if session_id not in self.scores:
raise LookupError(f"нет оценки занятия {session_id}")
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)
def snapshot(self, session_id: UUID) -> dict | None:
diff --git a/backend/tests/test_finding_review.py b/backend/tests/test_finding_review.py
new file mode 100644
index 0000000..50cb83f
--- /dev/null
+++ b/backend/tests/test_finding_review.py
@@ -0,0 +1,298 @@
+"""Разбор по отметкам (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["review"]["decision"] == "dismissed"
+ assert finding["review"]["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()
+
+ monkeypatch.setattr(finish_module, "coach", no_coach)
+ 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]["review"]["decision"] == "dismissed"
+ assert body["findings"][target]["review"]["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]["review"]["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]["review"]["decision"] == "dismissed"
+ assert audit is not None and audit.actor == "teacher"
+ assert "dismissed" in audit.detail and "50.0 → 100.0" in audit.detail
diff --git a/backend/tests/test_route_auth_contract.py b/backend/tests/test_route_auth_contract.py
index 5c9e5d0..fc1d881 100644
--- a/backend/tests/test_route_auth_contract.py
+++ b/backend/tests/test_route_auth_contract.py
@@ -83,6 +83,8 @@ HTTP_ROLE_GATE_POLICY = {
("http/sessions.py", "get", "/{session_id}/checklist"): ((),),
("http/sessions.py", "get", "/{session_id}/recording.wav"): (("INSTRUCTOR", "TRAINEE"),),
("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/trainees.py", "get", "/{trainee_id}/certificate.pdf"): ((),),
("http/trainees.py", "get", ""): (("ADMIN", "INSTRUCTOR"),),
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 2398064..8d69d3b 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -37,6 +37,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
Автоматическая оценка {report.score_auto.toFixed(0)} из 100
{report.overridden_by ? "до корректировки преподавателя" : "расчёт по критериям занятия"}
+ {report.score_reviewed != null && после разбора отметок: {report.score_reviewed.toFixed(0)} }
Упражнение {report.exercise === "dds" ? "ДДС" : report.exercise === "card" ? "КИО" : report.exercise === "call" ? "Вызов 112" : "112 → ДДС"}
@@ -144,6 +145,10 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {finding.source === "instructor" && отметка преподавателя {finding.author ?? ""}
}
+ {finding.review &&
+ {finding.review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем: {finding.review.reason}
+
}
))}
diff --git a/frontend/src/features/debrief/FindingReview.tsx b/frontend/src/features/debrief/FindingReview.tsx
new file mode 100644
index 0000000..7465dbb
--- /dev/null
+++ b/frontend/src/features/debrief/FindingReview.tsx
@@ -0,0 +1,146 @@
+// Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
+//
+// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
+// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
+
+import { 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 {
+ 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>({});
+ const [code, setCode] = useState(codes[0]);
+ const [card, setCard] = useState(cards.length ? "1" : "");
+ 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) {
+ 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;
+ }
+ void run(async () => {
+ const updated = await send(base, {
+ code, fact: fact.trim(), norm: norm.trim(), card: card ? Number(card) : null,
+ });
+ setFact("");
+ setNorm("");
+ return updated;
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index 3c1f435..fa5184e 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -7,6 +7,7 @@
import { useEffect, useRef, useState } from "react";
import { Debrief } from "@/features/debrief/Debrief";
+import { FindingReview } from "@/features/debrief/FindingReview";
import { Director } from "@/features/instructor/Director";
import { ScoreWeightsEditor } from "@/features/instructor/ScoreWeightsEditor";
import { ScenarioEditor } from "@/features/instructor/ScenarioEditor";
@@ -801,6 +802,8 @@ export function Instructor() {
{session.report && <>
+
Ручная корректировка оценки
Автоматическая оценка: {session.report.score_auto.toFixed(1)} из 100. Меняйте её только при необходимости: причина сохранится в журнале вместе с вашим логином.
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 7735a2b..18fbdac 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -267,6 +267,22 @@ export interface Finding {
competency?: Competency | null;
transcript_ref?: string | null;
at?: string | null;
+ metric_key?: string | null;
+ card?: number | null;
+ service?: string | null;
+ author?: string | null;
+ review?: FindingReview | null;
+}
+
+/** Решение преподавателя по отметке на разборе. */
+export type FindingDecision = "confirmed" | "dismissed";
+
+/** Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся. */
+export interface FindingReview {
+ decision: FindingDecision;
+ reason: string;
+ author: string;
+ at: string;
}
/** Источник объяснимой отметки; `judge` оставлен для старых отчётов. */
@@ -407,6 +423,10 @@ export interface Metric {
passed: boolean;
weight?: number;
credit?: number | null;
+ card?: number | null;
+ service?: string | null;
+ passed_auto?: boolean | null;
+ credit_auto?: number | null;
}
export interface ModeSet {
@@ -608,6 +628,7 @@ export interface SessionReport {
self_assessment_diff?: SelfAssessmentDiff | null;
notes?: Array;
score_auto: number;
+ score_reviewed?: number | null;
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 11f3c1e..e974e09 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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 small { font-size: 14px; font-weight: 500; }
.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-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; }
From 3f5f57877a45be7b50d16707a56a7fe014c857f7 Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 16:25:02 +0300
Subject: [PATCH 02/41] =?UTF-8?q?test:=20HTTP-=D1=82=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D1=8B=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BE?=
=?UTF-8?q?=D1=82=D0=BC=D0=B5=D1=82=D0=BE=D0=BA=20=D0=BD=D0=B5=20=D1=82?=
=?UTF-8?q?=D1=80=D0=B5=D0=B1=D1=83=D1=8E=D1=82=20PostgreSQL?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_finding_review.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/backend/tests/test_finding_review.py b/backend/tests/test_finding_review.py
index 50cb83f..227d74d 100644
--- a/backend/tests/test_finding_review.py
+++ b/backend/tests/test_finding_review.py
@@ -160,7 +160,16 @@ def lesson(monkeypatch):
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()
From a998b0c2f75aecd5b75d0b3b9e4f0c31580f26a3 Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 16:26:10 +0300
Subject: [PATCH 03/41] =?UTF-8?q?lct-37:=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?=
=?UTF-8?q?=D1=82=D0=B8=D0=B2=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?=
=?UTF-8?q?=20=D0=94=D0=94=D0=A1=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=BB?=
=?UTF-8?q?=D0=B0=D0=B4=20=D0=B1=D1=80=D0=B8=D0=B3=D0=B0=D0=B4=D1=8B,=20?=
=?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20D6=20=D0=B8=20?=
=?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5?=
=?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/domain/events.py | 2 +
backend/app/scoring/dispatcher.py | 89 ++++++++++++++++-
backend/app/scoring/export.py | 2 +
backend/app/scoring/taxonomy.py | 1 +
backend/app/session/finish.py | 5 +-
backend/app/session/score.py | 5 +-
backend/tests/test_dds_exercise.py | 4 +
backend/tests/test_dispatcher_scoring.py | 96 ++++++++++++++++++-
frontend/src/features/debrief/Debrief.tsx | 3 +-
.../instructor/ScoreWeightsEditor.tsx | 1 +
frontend/src/pages/instructor/Instructor.tsx | 11 ++-
frontend/src/shared/types/generated.ts | 1 +
12 files changed, 213 insertions(+), 7 deletions(-)
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 3cd3790..70ac045 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
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)
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)
require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict)
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 24e031a..ab81dd2 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -16,6 +16,7 @@ from app.domain.statuses import (
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
+ PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
@@ -27,6 +28,47 @@ from app.domain.timers import GOST_REF
#: Статусы, означающие, что реагирование действительно шло.
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
+
+
+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 _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding:
@@ -93,6 +135,8 @@ def evaluate_dispatcher(
reply_text: str = "",
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
+ phone_reports: list[PhoneReportRecord] | None = None,
+ report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@@ -264,15 +308,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
# 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}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
+ reported_done = any(report.phase == "completed" for report in reports)
findings.append(
_finding(
service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца",
- fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
+ fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ + ("; бригада доложила о завершении работ" if reported_done else ""),
norm=(
"отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной"
@@ -280,6 +327,32 @@ def evaluate_dispatcher(
)
)
+ # D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
+ # Код новый не вводится: это тот же неполный ход работ из памятки,
+ # только с измеримым временем реакции на доклад.
+ terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
+ limit_s = report_reaction_ms // 1000
+ for report, delay_ms in report_reactions(marks, reports):
+ label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
+ source = f"доклад бригады «{report.crew}» {REPORT_LABELS[report.phase]}"
+ if delay_ms is None and not terminal:
+ finding = _finding(
+ service, ErrorCode.D6,
+ f"{service}: по докладу бригады статус «{label}» не отмечен",
+ fact=f"{source} получен, статус «{label}» не отмечен",
+ norm=f"отметить статус по докладу не позже {limit_s} с",
+ )
+ elif delay_ms is not None and delay_ms > report_reaction_ms:
+ finding = _finding(
+ service, ErrorCode.D6,
+ f"{service}: статус «{label}» отмечен позже норматива после доклада",
+ fact=f"статус «{label}» отмечен через {round(delay_ms / 1000)} с; {source}",
+ norm=f"отметить статус по докладу не позже {limit_s} с",
+ )
+ else:
+ continue
+ findings.append(finding.model_copy(update={"metric_key": "dds_report_reaction"}))
+
return findings
@@ -290,6 +363,7 @@ def dispatcher_metrics(
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
+ report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@@ -355,4 +429,17 @@ def dispatcher_metrics(
notes_complete,
"; ".join(notes) if notes else "комментарии к статусам не внесены",
"к каждой ручной отметке добавить основание и содержание полученных сведений")
+ # Метрика есть только там, где бригада докладывала: без докладов
+ # реагировать не на что, и отсутствие звонков не штраф.
+ reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
+ reactions = report_reactions(marks, reports)
+ if reactions:
+ add("dds_report_reaction", "реакция на доклады бригады",
+ all(delay is not None and delay <= report_reaction_ms for _, delay in reactions),
+ "; ".join(
+ f"{REPORT_LABELS[report.phase]}: "
+ + (f"{round(delay / 1000)} с" if delay is not None else "статус не отмечен")
+ for report, delay in reactions
+ ),
+ f"статус по каждому докладу не позже {report_reaction_ms // 1000} с")
return metrics
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 9ee879b..1eeafc7 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -54,6 +54,8 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
+ row("Критерии", "", "Лимит реакции на доклад бригады, с",
+ report.criteria.dds_report_reaction_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index 28f8dc8..be96cd1 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -31,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
+ "dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 124e4f6..c729346 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -48,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision
+ reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher(
entries=card.status_log,
services=services,
@@ -57,10 +58,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text,
expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason,
+ phone_reports=card.phone_reports,
+ report_reaction_ms=reaction_ms,
)
metrics = dispatcher_metrics(
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)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
diff --git a/backend/app/session/score.py b/backend/app/session/score.py
index 00e69a4..2ef6501 100644
--- a/backend/app/session/score.py
+++ b/backend/app/session/score.py
@@ -219,6 +219,7 @@ async def score_session(
if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services)
+ reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log,
services=services,
@@ -228,10 +229,12 @@ async def score_session(
reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason,
+ phone_reports=live_card.phone_reports,
+ report_reaction_ms=reaction_ms,
))
result.metrics.extend(dispatcher_metrics(
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,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index 519c2f2..b027fad 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client,
criteria={
"decision_time_limit_seconds": 45,
+ "dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"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,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
+ "dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
@@ -797,6 +799,8 @@ 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 not score["findings"]
+ reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
+ assert reaction and all(m["passed"] for m in reaction), "каждый доклад отмечен вовремя"
finally:
control.__exit__(None, None, None)
diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py
index 8bc5c43..f6d562c 100644
--- a/backend/tests/test_dispatcher_scoring.py
+++ b/backend/tests/test_dispatcher_scoring.py
@@ -1,10 +1,10 @@
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
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.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
@@ -294,3 +294,95 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
deadline_ms=30_000, elapsed_ms=0,
)
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.fact == "о прибытии: 10 с"
+
+
+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 "«Прибытие» отмечен через 70 с" in findings[0].fact
+ assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
+ assert findings[0].norm == "отметить статус по докладу не позже 45 с"
+ assert not metric.passed and metric.norm == "статус по каждому докладу не позже 45 с"
+
+
+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.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, "статус раньше доклада — не нарушение"
+
+ strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
+ assert "не позже 20 с" in strict[0].norm
+
+
+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_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
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 8d69d3b..0b3230e 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -75,7 +75,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
- {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}>}
+ {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады —
+ {` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}>}
{report.exercise && report.exercise !== "dds" && <>
diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
index 9f81065..9bced96 100644
--- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx
+++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
@@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
+ { key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index fa5184e..55608f2 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -86,6 +86,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
+ const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState>({});
@@ -300,6 +301,7 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
+ dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
@@ -307,7 +309,7 @@ export function Instructor() {
});
if (sent) toStart.current = null;
}, [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,
randomScenario, visibleScenarios]);
@@ -649,6 +651,13 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.
}
+ {(exercise === "dds" || exercise === "card_dds") &&
+ Реакция на доклад бригады, секунд
+
+ По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.
+ }
Допустимо нарушенных метрик
;
From b77d65c82e19785ca744521c85f87b925654c17d Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 16:35:13 +0300
Subject: [PATCH 04/41] =?UTF-8?q?lct-39:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0?=
=?UTF-8?q?=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D1=8F=20=D1=81=20=D0=BF?=
=?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0,=20=D1=82=D0=B0=D0=B9=D0=BC?=
=?UTF-8?q?=D0=B5=D1=80=D1=8B=20=D0=B8=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5?=
=?UTF-8?q?=D0=B4=D1=8C=20=D0=94=D0=94=D0=A1=20=D1=81=D1=82=D0=BE=D1=8F?=
=?UTF-8?q?=D1=82=20=D0=BD=D0=B0=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BF?=
=?UTF-8?q?=D1=80=D0=BE=D1=81=D1=82=D0=BE=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 2 +
backend/app/api/ws/call.py | 5 +
backend/app/api/ws/control.py | 30 ++-
backend/app/api/ws/station.py | 3 +
backend/app/domain/events.py | 29 ++-
backend/app/domain/statuses.py | 2 +
backend/app/scoring/report.py | 1 +
backend/app/session/hub.py | 2 +-
backend/app/session/pg_store.py | 12 ++
backend/app/session/state.py | 46 ++++-
backend/app/session/store.py | 19 +-
backend/app/session/timers.py | 33 +++-
backend/tests/test_call_privacy.py | 1 +
backend/tests/test_checkpoint_model.py | 4 +
backend/tests/test_session_pause.py | 176 ++++++++++++++++++
frontend/src/features/call/useCall.ts | 6 +-
.../src/features/instructor/useControl.ts | 4 +-
frontend/src/features/observe/useObserve.ts | 9 +-
frontend/src/pages/dds/Dds.tsx | 4 +
frontend/src/pages/instructor/Instructor.tsx | 10 +-
frontend/src/pages/trainee/Call.tsx | 2 +
frontend/src/shared/types/generated.ts | 29 ++-
frontend/src/styles.css | 1 +
23 files changed, 410 insertions(+), 20 deletions(-)
create mode 100644 backend/tests/test_session_pause.py
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index c22ddb6..ea7e2d1 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -101,6 +101,7 @@ class ActiveSessionOut(BaseModel):
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
+ paused: bool = False
def _out(session) -> SessionOut:
@@ -262,6 +263,7 @@ async def active(
),
dds_statuses=latest_statuses,
dds_snapshot=station,
+ paused=state.paused,
))
return result
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 2d0f0d4..fc74cb0 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -191,6 +191,11 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
))
return
+ if state.paused:
+ hub.to_trainee(session_id, ErrorEvent(
+ code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
+ ))
+ return
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py
index eb1ab26..367c3d7 100644
--- a/backend/app/api/ws/control.py
+++ b/backend/app/api/ws/control.py
@@ -41,6 +41,7 @@ from app.domain.events import (
ModeSet,
ReferenceStarted,
SessionEnded,
+ SessionPaused,
StationState,
)
from app.domain.roles import Role
@@ -51,7 +52,14 @@ from app.session.access import can_access
from app.session.hub import hub
from app.session.finish import end_session, override_score
from app.session.state import SessionState, now_utc
-from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
+from app.session.store import (
+ LessonIdentity,
+ LessonPaused,
+ LessonRequest,
+ LessonResumed,
+ NoteAdded,
+ ScoreOverridden,
+)
from app.voice.models import get_voice_models
from app.voice.pipeline import FILLERS, prefetch
@@ -344,6 +352,26 @@ async def _command(session_id: UUID, event, who) -> None:
await _start(session_id, event, who)
case "session.stop":
await _stop(session_id)
+ case "session.pause":
+ if state is None or state.ended or state.paused:
+ return
+ state.pause()
+ hub.record(session_id, LessonPaused(
+ at=state.paused_at, author=who.login, role=who.role.value,
+ ))
+ hub.broadcast(session_id, SessionPaused(paused=True))
+ hub.to_station(session_id, SessionPaused(paused=True))
+ case "session.resume":
+ if state is None or state.ended or not state.paused:
+ return
+ paused_ms_before = state.total_paused_ms
+ state.resume()
+ hub.record(session_id, LessonResumed(
+ at=now_utc(), author=who.login, role=who.role.value,
+ paused_ms=state.total_paused_ms - paused_ms_before,
+ ))
+ hub.broadcast(session_id, SessionPaused(paused=False))
+ hub.to_station(session_id, SessionPaused(paused=False))
case "instructor_note.add":
if state is not None:
state.notes.append({
diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py
index 8481c09..3a93b81 100644
--- a/backend/app/api/ws/station.py
+++ b/backend/app/api/ws/station.py
@@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
async def _handle(session_id: UUID, state, event) -> None:
+ if state.paused:
+ _error(session_id, "Пауза, ждите преподавателя")
+ return
outcome = state.desk.apply(event, state)
if outcome.error is not None:
_error(session_id, outcome.error)
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 495708e..3b6a6f1 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -274,6 +274,13 @@ class ErrorEvent(BaseModel):
message: str
+class SessionPaused(BaseModel):
+ """Пульт остановил или возобновил время занятия (lct-39)."""
+
+ type: Literal["session.paused"] = "session.paused"
+ paused: bool
+
+
ServerToTrainee = Annotated[
CallIncoming
| CardBriefing
@@ -293,6 +300,7 @@ ServerToTrainee = Annotated[
| TimerTick
| CallEnded
| ScoreReady
+ | SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@@ -404,6 +412,8 @@ class SessionSnapshot(BaseModel):
timers: list[TimerSnapshot]
hints_used: int = 0
ended: bool = False
+ #: Пульт остановил время занятия (lct-39).
+ paused: bool = False
class TranscriptAppend(BaseModel):
@@ -458,6 +468,7 @@ ServerToObserver = Annotated[
| ScoreReady
| InstructorNoteShown
| ReferenceStarted
+ | SessionPaused
| ErrorEvent,
Field(discriminator="type"),
]
@@ -518,13 +529,25 @@ class SessionStop(BaseModel):
type: Literal["session.stop"] = "session.stop"
+class SessionPause(BaseModel):
+ """Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
+
+ type: Literal["session.pause"] = "session.pause"
+
+
+class SessionResume(BaseModel):
+ type: Literal["session.resume"] = "session.resume"
+
+
InstructorToServer = Annotated[
ScenarioStart
| DirectorInject
| ReferencePlay
| InstructorNoteAdd
| ScoreOverride
- | SessionStop,
+ | SessionStop
+ | SessionPause
+ | SessionResume,
Field(discriminator="type"),
]
@@ -665,7 +688,7 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
- | ScoreReady | CommandAck | ErrorEvent,
+ | ScoreReady | CommandAck | SessionPaused | ErrorEvent,
Field(discriminator="type"),
]
@@ -783,6 +806,8 @@ class SessionReport(BaseModel):
score_final: float
overridden_by: str | None = None
override_comment: str | None = None
+ #: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
+ total_paused_ms: int = 0
class Principal(BaseModel):
diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py
index 2f5be73..133ea46 100644
--- a/backend/app/domain/statuses.py
+++ b/backend/app/domain/statuses.py
@@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
pending_cards_count: int = 0
next_arrival_in_seconds: int | None = None
max_waiting_cards: int = 3
+ #: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
+ paused: bool = False
diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py
index 3f7a9aa..764dfd9 100644
--- a/backend/app/scoring/report.py
+++ b/backend/app/scoring/report.py
@@ -111,4 +111,5 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
override_comment=score.get("override_comment"),
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
if score.get("ai_coaching") else None),
+ total_paused_ms=state.total_paused_ms,
)
diff --git a/backend/app/session/hub.py b/backend/app/session/hub.py
index 056c616..c894660 100644
--- a/backend/app/session/hub.py
+++ b/backend/app/session/hub.py
@@ -347,7 +347,7 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
- if state.dds_phase:
+ if state.dds_phase and not state.paused:
async with self.operation(session_id) as op:
active_before = state.desk.active_id
op.persist = bool(state.desk.deliver_due())
diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py
index b35e33c..24b1e0f 100644
--- a/backend/app/session/pg_store.py
+++ b/backend/app/session/pg_store.py
@@ -32,7 +32,9 @@ from app.session.store import (
HintRecorded,
LessonEnded,
LessonIdentity,
+ LessonPaused,
LessonRequest,
+ LessonResumed,
LessonStarted,
NoteAdded,
Record,
@@ -196,6 +198,16 @@ class PostgresSessionStore:
object_id=str(session_id),
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
))
+ case LessonPaused(at=at, author=author, role=role):
+ db.add(AuditLog(
+ actor=author, role=role, action="session.pause",
+ object_id=str(session_id), detail=f"at={at.isoformat()}",
+ ))
+ case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
+ db.add(AuditLog(
+ actor=author, role=role, action="session.resume",
+ object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
+ ))
case LessonStarted(at=at):
await db.execute(
update(Session).where(Session.id == session_id).values(started_at=at)
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 166e611..aed1ea7 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -105,6 +105,12 @@ class PersistedSession(BaseModel):
started_at: datetime | None = None
ended_at: datetime | None = None
end_reason: CallEndReason | None = None
+ #: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
+ paused: bool = False
+ #: Момент постановки на паузу — им же считается длительность простоя при снятии.
+ paused_at: datetime | None = None
+ #: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
+ total_paused_ms: int = 0
#: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None
dispatched_at: datetime | None = None
@@ -181,6 +187,33 @@ class SessionState(PersistedSession):
"""Единственная точка, где событие двигает таймеры."""
self.timers.on_event(event_type)
+ def pause(self) -> None:
+ """Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
+ if self.paused:
+ return
+ now = time.monotonic()
+ self.timers.pause(now)
+ for card in self.desk.cards.values():
+ card.timers.pause(now)
+ self.paused = True
+ self.paused_at = now_utc()
+
+ def resume(self) -> None:
+ """Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
+ if not self.paused:
+ return
+ now = time.monotonic()
+ self.timers.resume(now)
+ for card in self.desk.cards.values():
+ card.timers.resume(now)
+ if self.paused_at is not None:
+ idle = now_utc() - self.paused_at
+ self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
+ if self.desk.next_arrival_at is not None:
+ self.desk.next_arrival_at += idle
+ self.paused = False
+ self.paused_at = None
+
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
entry = TranscriptEntry(
ref=f"u{len(self.transcript) + 1}",
@@ -319,14 +352,15 @@ class SessionState(PersistedSession):
services = self._managed_services_for(recipients)
log = list(card.status_log) if card is not None else []
statuses = {service: current(log, service) for service in services}
+ now_mono = time.monotonic()
overdue = False
if card is not None:
- elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
+ # Через таймер, а не через `dispatched_at`: часы разошлись бы с
+ # паузой, которая держит `current_ms` на месте (lct-39).
+ ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
limit = card.timers.limits[TimerCode.DDS_ACK]
- overdue = elapsed is None and (
- (now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
- )
- now_mono = time.monotonic()
+ elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
+ overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
queue_cards: list[DdsQueueCard] = []
for item in (desk.ordered() if self.dds_phase else []):
recipients_for_card = list(item.dispatched_card.notify)
@@ -397,6 +431,7 @@ class SessionState(PersistedSession):
if desk.next_arrival_at is not None else None
),
max_waiting_cards=desk.max_waiting,
+ paused=self.paused,
)
def card_received_event(self):
@@ -445,4 +480,5 @@ class SessionState(PersistedSession):
timers=self.shown_timers().snapshot(),
hints_used=len(self.hints_shown),
ended=self.ended,
+ paused=self.paused,
)
diff --git a/backend/app/session/store.py b/backend/app/session/store.py
index ea053b1..e1770fa 100644
--- a/backend/app/session/store.py
+++ b/backend/app/session/store.py
@@ -83,8 +83,25 @@ class ScoreOverridden:
comment: str
+@dataclass(frozen=True)
+class LessonPaused:
+ at: datetime
+ author: str
+ role: str
+
+
+@dataclass(frozen=True)
+class LessonResumed:
+ at: datetime
+ author: str
+ role: str
+ #: Длительность этой конкретной паузы — видно в аудите без пересчёта.
+ paused_ms: int
+
+
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
- | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
+ | LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
+ | LessonPaused | LessonResumed)
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index d3338e6..c8a4835 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -83,6 +83,9 @@ class Timer(BaseModel):
elapsed_ms: int = 0
attempt: int = 1
stopped: bool = False
+ #: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
+ #: считает новой попыткой, в отличие от `stopped` (lct-39).
+ paused: bool = False
@model_serializer(mode="wrap")
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
@@ -100,10 +103,13 @@ class Timer(BaseModel):
data = dict(data)
elapsed = max(0, int(data.get("elapsed_ms", 0)))
stopped = bool(data.get("stopped"))
- downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
+ paused = bool(data.get("paused"))
+ # На паузе занятие не простаивало без присмотра — оно ждало
+ # преподавателя, и это время не досчитывается таймеру при перезапуске.
+ downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
started = data.pop("started")
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
- data["elapsed_ms"] = elapsed if stopped else 0
+ data["elapsed_ms"] = elapsed if (stopped or paused) else 0
return data
def start(self, now: float) -> None:
@@ -125,6 +131,19 @@ class Timer(BaseModel):
return self.elapsed_ms
return int((now - self.started_at) * 1000)
+ def pause(self, now: float) -> None:
+ """Зафиксировать прошедшее время и остановить ход часов до `resume`."""
+ if self.started_at is not None and not self.stopped:
+ self.elapsed_ms = self.current_ms(now)
+ self.started_at = None
+ self.paused = True
+
+ def resume(self, now: float) -> None:
+ """Продолжить с той же отметки — простой в счёт не идёт."""
+ if self.paused:
+ self.started_at = now - self.elapsed_ms / 1000
+ self.paused = False
+
class SessionTimers(BaseModel):
"""Набор таймеров одной сессии. `limits` приходит из конфига —
@@ -142,6 +161,16 @@ class SessionTimers(BaseModel):
for code in STOPS.get(event_type, ()):
self.timers.setdefault(code, Timer(code=code)).stop(now)
+ def pause(self, now: float | None = None) -> None:
+ now = time.monotonic() if now is None else now
+ for timer in self.timers.values():
+ timer.pause(now)
+
+ def resume(self, now: float | None = None) -> None:
+ now = time.monotonic() if now is None else now
+ for timer in self.timers.values():
+ timer.resume(now)
+
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
"""Только запущенные таймеры: показывать нули по нормативам,
до которых занятие ещё не дошло, значит пугать курсанта зря."""
diff --git a/backend/tests/test_call_privacy.py b/backend/tests/test_call_privacy.py
index 4729d0e..b542c99 100644
--- a/backend/tests/test_call_privacy.py
+++ b/backend/tests/test_call_privacy.py
@@ -30,6 +30,7 @@ async def test_text_dialogue_provider_error_does_not_log_prompt_or_provider_body
monkeypatch.setattr(call.hub, "to_observers", lambda *_args: None)
state = SimpleNamespace(
ended=False,
+ paused=False,
exercise=Exercise.CARD,
dispatched_card=None,
caller=Caller(),
diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py
index 6ec7a19..4dd821a 100644
--- a/backend/tests/test_checkpoint_model.py
+++ b/backend/tests/test_checkpoint_model.py
@@ -57,6 +57,7 @@ def timers() -> SessionTimers:
TimerCode.DDS_ACK: Timer(code=TimerCode.DDS_ACK, started_at=MONO - 12.0, attempt=2),
TimerCode.ANSWER: Timer(code=TimerCode.ANSWER, started_at=MONO - 7.0,
elapsed_ms=7_000, attempt=3, stopped=True),
+ TimerCode.CARD_FILL: Timer(code=TimerCode.CARD_FILL, elapsed_ms=5_000, paused=True),
})
@@ -161,6 +162,9 @@ def full_state() -> SessionState:
resolve_comment="передано в другой регион",
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
+ paused=True,
+ paused_at=AT,
+ total_paused_ms=15_000,
)
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
new file mode 100644
index 0000000..39e8f13
--- /dev/null
+++ b/backend/tests/test_session_pause.py
@@ -0,0 +1,176 @@
+"""Пауза занятия с пульта преподавателя (lct-39).
+
+Часы — управляемый монотонный счётчик: тест продвигает их сам, а не спит,
+проверяя ровно то, что требует карточка — пауза не считается в норматив,
+переживает чекпойнт и пишется в аудит.
+"""
+
+import asyncio
+from datetime import timedelta
+from pathlib import Path
+from uuid import uuid4
+
+import pytest
+
+from app.api.ws import control as control_ws
+from app.domain.events import Exercise, LessonCriteria, SessionMode
+from app.domain.timers import TimerCode
+from app.scenarios.loader import load_file
+from app.session import timers as timers_module
+from app.session.checkpoint import dump_state, load_state
+from app.session.hub import hub
+from app.session.state import SessionState, now_utc
+from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
+from app.session.timers import Timer
+
+LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+
+
+class FakeClock:
+ """Управляемая замена `time.monotonic`: тест продвигает время явно."""
+
+ def __init__(self, start: float = 1_000.0) -> None:
+ self.now = start
+
+ def __call__(self) -> float:
+ return self.now
+
+ def advance(self, seconds: float) -> None:
+ self.now += seconds
+
+
+@pytest.fixture
+def clock(monkeypatch):
+ fake = FakeClock()
+ monkeypatch.setattr(timers_module.time, "monotonic", fake)
+ return fake
+
+
+def test_timer_pause_freezes_elapsed_until_resume(clock):
+ timer = Timer(code=TimerCode.DDS_ACK)
+ timer.start(clock.now)
+ clock.advance(10)
+
+ timer.pause(clock.now)
+ frozen = timer.current_ms(clock.now)
+ clock.advance(60) # долгая пауза — часы идут, таймер не должен
+
+ assert timer.current_ms(clock.now) == frozen == 10_000
+
+ timer.resume(clock.now)
+ clock.advance(15)
+
+ assert timer.current_ms(clock.now) == 25_000
+
+
+def test_session_pause_excludes_wait_from_dds_ack_deadline(clock):
+ """Карточка: 30 с норматива. Пауза 60 с внутри окна не должна дать D1 —
+ отметка приходит на 25-й секунде активного времени, а не 85-й реальной."""
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, criteria=LessonCriteria(decision_time_limit_seconds=30),
+ )
+ state.timers.limits[TimerCode.DDS_ACK] = 30_000
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ card = state.desk.active
+
+ clock.advance(10)
+ state.pause()
+ clock.advance(60)
+ state.resume()
+ clock.advance(15)
+ card.on_event("card.ack")
+
+ measured = card.timers.measured_ms(TimerCode.DDS_ACK)
+ assert measured == 25_000
+ assert measured <= card.timers.limits[TimerCode.DDS_ACK]
+
+
+def test_pause_freezes_queue_arrival_and_resume_shifts_it(clock, monkeypatch):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ second = scenario.model_copy(deep=True)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario,
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario, second], arrival_interval_seconds=30, max_waiting=3)
+
+ due_before = state.desk.next_arrival_at
+ assert due_before is not None
+
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+ state.pause()
+ idle = timedelta(minutes=1)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
+ state.resume()
+
+ # Следующая карточка не должна поступить раньше, чем простояла пауза.
+ assert state.desk.next_arrival_at == due_before + idle
+ assert state.desk.deliver_due(due_before + timedelta(seconds=1)) == 0
+
+
+def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario,
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ card = state.desk.active
+
+ clock.advance(5)
+ state.pause()
+ saved_at = now_utc()
+
+ payload = dump_state(state)
+ # Процесс лежал минуту — по часам сервера, не по monotonic этого теста.
+ restored_at = saved_at + timedelta(minutes=1)
+ restored = load_state(payload, restored_at)
+
+ assert restored.paused is True
+ restored_card = restored.desk.active
+ restored_timer = restored_card.timers.timers[TimerCode.DDS_ACK]
+ # Простоя в счёт не пошло: элапсед остался тем же, что был на паузе.
+ assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
+ assert restored_timer.started_at is None
+
+
+def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, owner_login="teacher",
+ )
+ from app.session.dds import prepare_queue
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+
+ store = MemorySessionStore()
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ monkeypatch.setattr(hub, "store", store)
+ who = type("Who", (), {"login": "teacher", "role": type("R", (), {"value": "instructor"})()})()
+
+ async def run():
+ async with hub.operation(state.session_id):
+ await control_ws._command(
+ state.session_id, type("E", (), {"type": "session.pause"})(), who,
+ )
+ async with hub.operation(state.session_id):
+ await control_ws._command(
+ state.session_id, type("E", (), {"type": "session.resume"})(), who,
+ )
+
+ asyncio.run(run())
+
+ records = [record for _sid, batch in store.commits for record in batch]
+ assert any(isinstance(record, LessonPaused) for record in records)
+ assert any(isinstance(record, LessonResumed) for record in records)
+ assert state.paused is False
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index dac8097..01f01dc 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -50,6 +50,7 @@ export function useCall(sessionId: string | null) {
const [scoreReady, setScoreReady] = useState(false);
const { report, error: reportError } = useSessionReport(sessionId, scoreReady);
const [error, setError] = useState(null);
+ const [paused, setPaused] = useState(false);
const channel = useRef | null>(null);
const capture = useRef(null);
@@ -153,6 +154,9 @@ export function useCall(sessionId: string | null) {
case "score.ready":
setScoreReady(true);
break;
+ case "session.paused":
+ setPaused(event.paused);
+ break;
case "error":
setError(event.message);
break;
@@ -253,7 +257,7 @@ export function useCall(sessionId: string | null) {
return {
resolve,
- status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, card,
+ status, phase, incoming, briefing, cardSubmitted, lines, timers, callerSpeaking, micOn, error, paused, card,
checklist, selfAssessed, scoreReady, report, reportError,
answer, hangup, hint, patchKio, dispatch, submitCard, submitSelfAssessment, sendText,
};
diff --git a/frontend/src/features/instructor/useControl.ts b/frontend/src/features/instructor/useControl.ts
index 02b16d3..64f8efc 100644
--- a/frontend/src/features/instructor/useControl.ts
+++ b/frontend/src/features/instructor/useControl.ts
@@ -63,7 +63,9 @@ export function useControl(sessionId: string | null) {
);
const stop = useCallback(() => channel.current?.socket.send({ type: "session.stop" }), []);
+ const pause = useCallback(() => channel.current?.socket.send({ type: "session.pause" }), []);
+ const resume = useCallback(() => channel.current?.socket.send({ type: "session.resume" }), []);
const playReference = useCallback(() => channel.current?.socket.send({ type: "reference.play" }), []);
- return { status, openSessionId, start, note, inject, stop, playReference };
+ return { status, openSessionId, start, note, inject, stop, pause, resume, playReference };
}
diff --git a/frontend/src/features/observe/useObserve.ts b/frontend/src/features/observe/useObserve.ts
index 7505865..697dad0 100644
--- a/frontend/src/features/observe/useObserve.ts
+++ b/frontend/src/features/observe/useObserve.ts
@@ -29,6 +29,7 @@ export interface ObservedSession {
hintsUsed: number;
notes: Map;
ended: boolean;
+ paused: boolean;
report: SessionReport | null;
error: string | null;
}
@@ -45,6 +46,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
const [hintsUsed, setHintsUsed] = useState(0);
const [notes, setNotes] = useState>(new Map());
const [ended, setEnded] = useState(false);
+ const [paused, setPaused] = useState(false);
// Сессия, для которой отчёт уже стоит просить. Привязка к id, а не флаг:
// при переключении истории старый флаг не должен дёрнуть запрос новой сессии.
const [reportFor, setReportFor] = useState(null);
@@ -65,6 +67,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setHintsUsed(0);
setNotes(new Map());
setEnded(false);
+ setPaused(false);
setError(null);
if (!sessionId) return;
let active = true;
@@ -88,6 +91,7 @@ export function useObserve(sessionId: string | null): ObservedSession {
setTimers(event.timers);
setHintsUsed(event.hints_used ?? 0);
setEnded(event.ended ?? false);
+ setPaused(event.paused ?? false);
if (event.ended) loadReport();
break;
case "kio.state":
@@ -112,6 +116,9 @@ export function useObserve(sessionId: string | null): ObservedSession {
setEnded(true);
loadReport();
break;
+ case "session.paused":
+ setPaused(event.paused);
+ break;
case "error":
setError(event.message);
if (event.code === "session_not_found") loadReport();
@@ -127,5 +134,5 @@ export function useObserve(sessionId: string | null): ObservedSession {
return () => { active = false; channel.close(); };
}, [sessionId, queries]);
- return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, report, error };
+ return { status, title, mode, trainee, card, requiredFields, transcript, timers, hintsUsed, notes, ended, paused, report, error };
}
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 90d23df..b84a14a 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -233,6 +233,7 @@ export function Dds() {
const [checkText, setCheckText] = useState("");
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
+ const [paused, setPaused] = useState(false);
// Разбор просим только после `score.ready`; новая карточка снимает флаг.
const [scoreReady, setScoreReady] = useState(false);
const { report, loading: reportLoading, error: reportError, refetch: refetchReport } =
@@ -252,6 +253,7 @@ export function Dds() {
if (event.type === "station.state") {
const snapshot = event.snapshot as DdsSnapshot;
setStation(snapshot);
+ setPaused(snapshot.paused ?? false);
setZone(snapshot.zone_decision ?? null);
if (snapshot.card_id == null && (snapshot.pending_cards_count ?? 0) > 0) {
activeCardId.current = null;
@@ -298,6 +300,7 @@ export function Dds() {
} else if (event.type === "timer.tick") setTimers(event.timers);
else if (event.type === "error") { setError(event.message); setDialing(false); setAdvancePending(false); }
else if (event.type === "session.ended") { setEnded(true); setDialing(false); setAdvancePending(false); setPane(null); setView("registry"); }
+ else if (event.type === "session.paused") setPaused(event.paused);
else if (event.type === "score.ready") setScoreReady(true);
else if (event.type === "phone.report") {
setLastReport(event);
@@ -368,6 +371,7 @@ export function Dds() {
АРМ {role}: {isConnected ? "на связи" : status}
{error && Действие не принято: {error}
}
+ {paused && !ended && Пауза, ждите преподавателя
}
{ended && Занятие завершено. Оценка появится после расчёта.
}
{(ended || scoreReady) &&
Итог упражнения
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index 3c1f435..0dc44c0 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -695,7 +695,15 @@ export function Instructor() {
{sessionId && session.title && (
{session.ended ? "занятие завершено" : (
- Завершить занятие
+ <>
+ Завершить занятие {" "}
+ {session.paused ? (
+ Возобновить
+ ) : (
+ Пауза
+ )}
+ {session.paused && Занятие на паузе — время не идёт }
+ >
)}
)}
diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx
index 8b68057..a1a9c2c 100644
--- a/frontend/src/pages/trainee/Call.tsx
+++ b/frontend/src/pages/trainee/Call.tsx
@@ -126,6 +126,7 @@ export function Call() {
placeholder="Ваша реплика…" onChange={(event) => setTextTurn(event.target.value)} />
Отправить
+ {call.paused && Пауза, ждите преподавателя
}
{call.error && {call.error}
}
@@ -190,6 +191,7 @@ export function Call() {
: "Ожидайте входящего вызова"}
+ {call.paused && Пауза, ждите преподавателя
}
{call.error && {call.error}
}
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 7735a2b..f5b64e5 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -585,6 +585,17 @@ export interface SessionEnded {
/** Режим сессии. Меняет доступность подсказок и протоколирование, но не поведение звонящего (docs/product/MODES.md). */
export type SessionMode = "training" | "exam" | "self";
+/** Остановить время занятия; карточку курсанта команда не меняет (lct-39). */
+export interface SessionPause {
+ type: "session.pause";
+}
+
+/** Пульт остановил или возобновил время занятия (lct-39). */
+export interface SessionPaused {
+ type: "session.paused";
+ paused: boolean;
+}
+
/** Единица истории: из отчётов складываются профиль, дельта попыток, аналитика. */
export interface SessionReport {
session_id: string;
@@ -611,6 +622,11 @@ export interface SessionReport {
score_final: number;
overridden_by?: string | null;
override_comment?: string | null;
+ total_paused_ms?: number;
+}
+
+export interface SessionResume {
+ type: "session.resume";
}
/** Обязательно при подключении: монитор в классе включают посреди занятия. */
@@ -631,6 +647,7 @@ export interface SessionSnapshot {
timers: Array;
hints_used?: number;
ended?: boolean;
+ paused?: boolean;
}
export interface SessionStop {
@@ -667,6 +684,7 @@ export interface StationSnapshot {
pending_cards_count?: number;
next_arrival_in_seconds?: number | null;
max_waiting_cards?: number;
+ paused?: boolean;
}
/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
@@ -775,15 +793,15 @@ export interface ZoneDecision {
// Союзы по каналам — смотри docs/arch/CONTRACT.md#каналы
-export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | ErrorEvent;
+export type ServerToTrainee = CallIncoming | CardBriefing | TextTurnAccepted | CallStarted | SttPartial | SttFinal | CallerUtterance | TtsBegin | TtsEnd | TtsCancel | BgStart | BgStop | EraData | KioPatchOut | HintShown | TimerTick | CallEnded | ScoreReady | SessionPaused | ErrorEvent;
export type TraineeToServer = CallAnswer | CardSubmit | TextTurn | KioPatchIn | HintRequest | SelfAssessmentSubmit | DdsDispatch | CallResolve | CallHangup | CallbackDial;
-export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | ErrorEvent;
+export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | TimerTick | ModeSet | HintShown | BgStart | BgStop | CallerUtterance | SessionEnded | ScoreReady | InstructorNoteShown | ReferenceStarted | SessionPaused | ErrorEvent;
-export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop;
+export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | SessionStop | SessionPause | SessionResume;
-export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | ErrorEvent;
+export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
@@ -835,6 +853,9 @@ export type EventName =
| "score.ready"
| "self_assessment.submit"
| "session.ended"
+ | "session.pause"
+ | "session.paused"
+ | "session.resume"
| "session.snapshot"
| "session.stop"
| "station.finish"
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 11f3c1e..857e334 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -349,6 +349,7 @@ button:disabled { opacity: 0.5; cursor: default; }
.dds-status-action button { padding: 4px 8px; }
.dds-alert { padding: 10px 12px; margin: 0 0 12px; color: var(--violated); background: #fde8e8; border: 1px solid var(--violated); }
.dds-done { color: var(--ok); background: #e7f4e9; border-color: var(--ok); }
+.dds-paused { color: var(--warn); background: #fdf3d8; border-color: var(--warn); }
.dds-phone > label { display: block; margin: 14px 12px 5px; font-size: 13px; font-weight: 600; }
.dds-phone > select { display: block; width: calc(100% - 24px); margin: 0 12px; padding: 7px; box-sizing: border-box; }
.dds-dial { display: block; margin: 12px; width: calc(100% - 24px); text-align: center; background: #23577e; border-color: #23577e; color: white; font-weight: 600; }
From b6617d7df8ab6fa06942034701fff51c21b59cae Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 17:05:29 +0300
Subject: [PATCH 05/41] =?UTF-8?q?fix:=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BD=D1=8B=D0=B9=20=D1=83=D0=B7=D0=BB=D1=83?=
=?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=20=D0=BF=D1=80=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D0=BE=D0=B2=D0=BE,=20=D0=BD=D0=B5=20=D0=BE=D1=82=D0=B7=D1=8B?=
=?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
lct-42: узел кластера принимал вход другого узла первую секунду за отзыв
и рвал сокет FORBIDDEN. Неизвестный логин теперь проверяется разовым
SELECT auth_version, результат кэшируется, параллельные запросы одного
логина дедуплицируются; логина нет в users или БД недоступна — как раньше.
---
backend/app/api/auth.py | 72 +++++++--
backend/tests/test_auth_hardening.py | 215 +++++++++++++++++++++++++++
2 files changed, 275 insertions(+), 12 deletions(-)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 17af9ce..bb25c65 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -56,6 +56,14 @@ _vanished: set[str] = set()
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
+# One lock per login this node has not synced yet, so a concurrent HTTP
+# request and WS handshake for the same just-migrated login collapse into a
+# single SELECT instead of one each (lct-42).
+_lookup_locks: dict[str, asyncio.Lock] = {}
+
+
+class _AuthStateUnavailable(Exception):
+ """The one-shot lookup for a login unknown to this node could not reach PostgreSQL."""
async def _close_revoked(ws: WebSocket) -> None:
@@ -134,6 +142,36 @@ async def sync_generations() -> None:
_generations_synced_at = time.monotonic()
+async def _resolve_unknown_login(login: str) -> int | None:
+ """Look up a login absent from this node's cache without waiting a tick.
+
+ A revoked login stays cached in `_generations` (see `_vanished` above),
+ so absence from `_generations` unambiguously means "this node has not
+ synced this login yet" — an unrecognized login is checked directly
+ rather than treated as revoked. Returns the current epoch, or `None` if
+ the login is not (or no longer) in `users`. Raises
+ `_AuthStateUnavailable` if PostgreSQL cannot be reached; the caller
+ fails closed.
+ """
+ lock = _lookup_locks.setdefault(login, asyncio.Lock())
+ async with lock:
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached # a concurrent request already resolved it
+ try:
+ async with get_sessionmaker()() as db:
+ version = await db.scalar(
+ select(User.auth_version).where(User.login == login)
+ )
+ except Exception as exc: # noqa: BLE001 — fail closed, not FORBIDDEN
+ log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
+ raise _AuthStateUnavailable from exc
+ if version is None:
+ return None
+ _generations[login] = version
+ return version
+
+
async def watch_generations() -> None:
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
while True:
@@ -151,6 +189,21 @@ async def watch_generations() -> None:
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
+async def _send_auth_state_unavailable(scope, send) -> None:
+ if scope["type"] == "websocket":
+ await send({"type": "websocket.close", "code": 1013})
+ else:
+ await send({
+ "type": "http.response.start",
+ "status": 503,
+ "headers": [(b"content-type", b"application/json")],
+ })
+ await send({
+ "type": "http.response.body",
+ "body": b'{"detail":"auth_state_unavailable"}',
+ })
+
+
class AuthVersionMiddleware:
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
@@ -186,22 +239,17 @@ class AuthVersionMiddleware:
synced_at is None
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
):
- if scope["type"] == "websocket":
- await send({"type": "websocket.close", "code": 1013})
- else:
- await send({
- "type": "http.response.start",
- "status": 503,
- "headers": [(b"content-type", b"application/json")],
- })
- await send({
- "type": "http.response.body",
- "body": b'{"detail":"auth_state_unavailable"}',
- })
+ await _send_auth_state_unavailable(scope, send)
return
cookie_version = session.get("auth_generation")
version = _generations.get(login)
+ if version is None:
+ try:
+ version = await _resolve_unknown_login(login)
+ except _AuthStateUnavailable:
+ await _send_auth_state_unavailable(scope, send)
+ return
if version is None or cookie_version != version:
if session is not None:
session.clear()
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index eec963a..00cd8bf 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -410,6 +410,221 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
asyncio.run(run())
+def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
+ """Cluster handshake (lct-42): a login another node just authenticated is
+ fetched via a single SELECT rather than being treated as revoked, and the
+ result is cached so a second request for it does not query again."""
+ login = "peer-node-fresh-login"
+ calls = []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ calls.append(1)
+ return 5
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = 0
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called += 1
+
+ async def run():
+ auth.prime_generations({})
+
+ def make_scope():
+ return {
+ "type": "http", "path": "/api/admin/users",
+ "session": {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 5,
+ },
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(_message):
+ return None
+
+ first = InnerApp()
+ await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
+ assert first.called == 1, "a fresh, valid epoch must reach the route"
+ assert auth._generations[login] == 5
+ assert calls == [1]
+
+ second = InnerApp()
+ await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
+ assert second.called == 1
+ assert calls == [1], "cached epoch must not trigger a second SELECT"
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
+def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
+ login = "peer-node-deleted-login"
+
+ class FakeDb:
+ async def scalar(self, _query):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 3,
+ }
+ scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ async def send(_message):
+ return None
+
+ protected = InnerApp()
+ await auth.AuthVersionMiddleware(protected)(scope, receive, send)
+ assert protected.called, "route still runs but the session was cleared below"
+ assert cookie_session == {}
+ assert login not in auth._generations
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
+ login = "peer-node-concurrent-login"
+ calls = []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ calls.append(1)
+ await asyncio.sleep(0.01) # widen the window for a racing second caller
+ return 9
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ results = await asyncio.gather(
+ auth._resolve_unknown_login(login),
+ auth._resolve_unknown_login(login),
+ auth._resolve_unknown_login(login),
+ )
+ assert results == [9, 9, 9]
+ assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
+def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
+ login = "peer-node-db-outage-login"
+
+ class BrokenSession:
+ async def __aenter__(self):
+ raise OSError("database unavailable")
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ def __init__(self):
+ self.called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 0,
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ http_messages = []
+
+ async def http_send(message):
+ http_messages.append(message)
+
+ http_app = InnerApp()
+ await auth.AuthVersionMiddleware(http_app)(
+ {"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
+ receive, http_send,
+ )
+ assert not http_app.called
+ assert http_messages[0]["status"] == 503
+
+ ws_messages = []
+
+ async def ws_send(message):
+ ws_messages.append(message)
+
+ ws_app = InnerApp()
+ await auth.AuthVersionMiddleware(ws_app)(
+ {"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
+ receive, ws_send,
+ )
+ assert not ws_app.called
+ assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._lookup_locks.pop(login, None)
+
+
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
from app.config import get_settings
From 42f1da7f83513aa7a2003cddc0d79080b61e2b67 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 17:13:01 +0300
Subject: [PATCH 06/41] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=
=?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D0=B5?=
=?UTF-8?q?=D1=82=20=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=81=D0=B2=D0=B5?=
=?UTF-8?q?=D0=B6=D0=B8=D0=B9=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD?=
=?UTF-8?q?=D1=8B=D0=B9=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
lct-42: стейл-ответ SELECT, пришедший после конкурентного invalidate_login
на том же узле, откатывал поколение назад и снова принимал отозванную
cookie. После ожидания перепроверяем кэш и не перезаписываем более свежее
значение. Детерминированный тест гонки; SCALE-OUT.md отделяет старые
замеры от непроверенного кластерным смоуком изменения.
---
backend/app/api/auth.py | 6 +++++
backend/tests/test_auth_hardening.py | 39 ++++++++++++++++++++++++++++
2 files changed, 45 insertions(+)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index bb25c65..291350c 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -166,6 +166,12 @@ async def _resolve_unknown_login(login: str) -> int | None:
except Exception as exc: # noqa: BLE001 — fail closed, not FORBIDDEN
log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
raise _AuthStateUnavailable from exc
+ # A concurrent local revoke or sync tick may have written a newer
+ # generation while the SELECT above was in flight; the stale read
+ # must never clobber it back to an older, already-revoked epoch.
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached
if version is None:
return None
_generations[login] = version
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index 00cd8bf..d39c759 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -527,6 +527,45 @@ def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypa
auth._lookup_locks.pop(login, None)
+def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
+ """A stale SELECT reply landing after a concurrent local revoke must not
+ resurrect the revoked cookie's epoch (regression: lock only deduplicates
+ concurrent lookups, it does not order a lookup against a write)."""
+ login = "peer-node-race-login"
+ started = asyncio.Event()
+ resume = asyncio.Event()
+
+ class FakeDb:
+ async def scalar(self, _query):
+ started.set()
+ await resume.wait()
+ return 0 # the epoch as it stood before the concurrent revoke below
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ task = asyncio.ensure_future(auth._resolve_unknown_login(login))
+ await started.wait() # the SELECT is in flight, holding the login's lock
+ auth.invalidate_login(login, 1) # a local logout/edit races the reply
+ resume.set()
+ result = await task
+ assert result == 1, "the newer local revocation must win over the stale SELECT reply"
+ assert auth._generations[login] == 1
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ auth._lookup_locks.pop(login, None)
+
+
def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
login = "peer-node-concurrent-login"
calls = []
From 2304d94877215f14a07b5c048060e46eabcd4aa8 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 17:18:27 +0300
Subject: [PATCH 07/41] =?UTF-8?q?lct-39:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0?=
=?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BA=D0=B0=D0=B6=D0=B0=D0=B5?=
=?UTF-8?q?=D1=82=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D1=83=20=D0=94=D0=94?=
=?UTF-8?q?=D0=A1=20=D0=B8=20=D0=BD=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?=
=?UTF-8?q?=D1=80=D1=83=D0=B5=D1=82=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88?=
=?UTF-8?q?=D1=91=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82?=
=?UTF-8?q?=D0=B8=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 4 +-
backend/app/api/ws/call.py | 9 ++
backend/app/scoring/dispatcher.py | 15 +--
backend/app/session/state.py | 9 +-
backend/tests/test_session_pause.py | 175 +++++++++++++++++++++++++++-
5 files changed, 202 insertions(+), 10 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index ea7e2d1..89a4ee2 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -256,7 +256,9 @@ async def active(
),
dds_work_overdue_cards=sum(
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
- and timer.started_at is not None
+ # Пауза держит `started_at=None`, хотя таймер уже шёл: судить
+ # по нему одному спрятало бы уже случившееся нарушение (lct-39).
+ and (timer.started_at is not None or timer.paused)
and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.desk.cards.values()
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index fc74cb0..a8a3c48 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -31,6 +31,7 @@ from app.domain.events import (
PatchSource,
ScoreReady,
SessionMode,
+ SessionPaused,
StationState,
TimerTick,
TextTurnAccepted,
@@ -398,6 +399,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
_who, state = entered
with hub.trainee(session_id) as queue:
+ if state.paused:
+ # Переподключение не ждёт следующего `session.pause`: баннер должен
+ # быть на экране сразу, раз сервер уже отклоняет команды (lct-39).
+ hub.to_trainee(session_id, SessionPaused(paused=True))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@@ -440,6 +445,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
# Бинарные кадры — аудио, текстовые — события. Направление определяется
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
if message.get("bytes") is not None:
+ if state.paused:
+ # JSON-команды отказывает `_handle`; бинарные кадры сюда
+ # не заходят, поэтому пауза глушит звук здесь же (lct-39).
+ continue
if state.exercise is Exercise.CALL:
_on_audio(session_id, state, message["bytes"])
else:
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 0be2530..8ad28f1 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -22,7 +22,7 @@ from app.domain.statuses import (
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
-from app.domain.timers import GOST_REF
+from app.domain.timers import GOST_REF, TimerCode
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@@ -282,7 +282,7 @@ def evaluate_dispatcher(
def dispatcher_metrics(
- state,
+ card,
deadline_ms: int,
expected_decision: str = "accept",
expected_decision_reason: str | None = None,
@@ -295,8 +295,11 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса.
"""
metrics: list[Metric] = []
- for service in (services if services is not None else state.managed_services()):
- marks = [mark for mark in state.status_log if mark.service == service]
+ # Через таймер, а не `primary.at - dispatched_at`: календарная разница
+ # включила бы паузу преподавателя в норматив (lct-39).
+ elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
+ for service in (services if services is not None else card.managed_services()):
+ marks = [mark for mark in card.status_log if mark.service == service]
statuses = {mark.status for mark in marks}
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
@@ -307,8 +310,6 @@ def dispatcher_metrics(
))
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
- elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
- if primary and state.dispatched_at else None)
add("dds_primary", "решение по карточке", primary is not None,
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
@@ -326,7 +327,7 @@ def dispatcher_metrics(
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
continue
- crew = state.crew_assignments.get(service)
+ crew = card.crew_assignments.get(service)
add("dds_crew", "назначение бригады", bool(crew),
crew or "бригада не выбрана",
"необходимую бригаду выбирает ДДС вручную", 2.0)
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index aed1ea7..51bd24d 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -454,7 +454,14 @@ class SessionState(PersistedSession):
return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime:
- """Закрыть занятие; возвращает момент окончания для журнала."""
+ """Закрыть занятие; возвращает момент окончания для журнала.
+
+ Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
+ не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
+ навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
+ потеряет последний, ещё не закрытый интервал паузы (lct-39).
+ """
+ self.resume()
self.ended_at = now_utc()
self.end_reason = reason
return self.ended_at
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
index 39e8f13..602889a 100644
--- a/backend/tests/test_session_pause.py
+++ b/backend/tests/test_session_pause.py
@@ -6,18 +6,25 @@
"""
import asyncio
+import time
from datetime import timedelta
from pathlib import Path
from uuid import uuid4
import pytest
+from fastapi.testclient import TestClient
+from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws
-from app.domain.events import Exercise, LessonCriteria, SessionMode
+from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode
+from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
+from app.main import app
from app.scenarios.loader import load_file
+from app.scoring.dispatcher import dispatcher_metrics
from app.session import timers as timers_module
from app.session.checkpoint import dump_state, load_state
+from app.session.finish import end_session
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
@@ -26,6 +33,20 @@ from app.session.timers import Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
+ scenario=scenario, owner_login="teacher",
+ )
+ state.timers.limits[TimerCode.DDS_ACK] = criteria_ms
+ from app.session.dds import prepare_queue
+
+ prepare_queue(state, [scenario.model_copy(deep=True)])
+ return state, state.desk.active
+
+
class FakeClock:
"""Управляемая замена `time.monotonic`: тест продвигает время явно."""
@@ -174,3 +195,155 @@ def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
assert any(isinstance(record, LessonPaused) for record in records)
assert any(isinstance(record, LessonResumed) for record in records)
assert state.paused is False
+
+
+def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
+ """Регрессия: `dispatcher_metrics` считала `primary.at - dispatched_at` —
+ календарную разницу, которую пауза не трогает. Метрика должна брать то
+ же замороженное время, что и таймер на экране."""
+ state, card = _dds_state_with_active_card(clock)
+ services = state.card_services(card)
+ service = services[0]
+
+ clock.advance(10)
+ state.pause()
+ clock.advance(60)
+ state.resume()
+ clock.advance(15)
+ card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
+ card.on_event("card.ack")
+
+ metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
+ assert metric.passed is True
+ assert "25.0" in metric.fact
+
+
+def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock, monkeypatch):
+ """Регрессия: занятие, завершённое прямо на паузе, оставляло `paused=True`
+ навсегда — самооценка и отчёт отклонялись баннером «Пауза», а простой,
+ не закрытый явным `session.resume`, не попадал в `total_paused_ms`."""
+ from app.session import finish as finish_module
+
+ async def no_coach(_metrics):
+ class NoCoaching:
+ def model_dump(self, **_kwargs):
+ return {}
+ return NoCoaching()
+
+ monkeypatch.setattr(finish_module, "coach", no_coach)
+
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ state = SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
+ scenario=scenario, owner_login="teacher",
+ )
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+ clock.advance(5)
+ state.pause()
+ clock.advance(30) # преподаватель завершает занятие, не возобновив его
+ idle = timedelta(seconds=45)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
+
+ async def run():
+ async with hub.operation(state.session_id):
+ await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
+
+ asyncio.run(run())
+
+ assert state.ended
+ assert state.paused is False
+ assert state.total_paused_ms >= 30_000
+
+
+def test_dds_work_overdue_survives_pause():
+ """Регрессия: `dds_work_overdue_cards` требовал `started_at is not None`,
+ который пауза обнуляет — уже случившееся нарушение исчезало из реестра."""
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ from app.session.dds import build_card
+ from app.session.timers import SessionTimers
+
+ limits = {**SessionTimers().limits, TimerCode.DDS_WORK: 1_000}
+ card = build_card(scenario, 0, limits)
+ timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
+ timer.start(time.monotonic() - 5.0) # уже превысил норматив в 1 с
+ timer.pause(time.monotonic())
+
+ overdue = (
+ timer.started_at is not None or timer.paused
+ ) and not timer.stopped and timer.current_ms(time.monotonic()) > limits[TimerCode.DDS_WORK]
+ assert overdue is True
+
+
+@pytest.fixture
+def http_client(monkeypatch):
+ async def audit_in_memory(*_args, **_kwargs):
+ return None
+
+ monkeypatch.setattr(sessions_http, "audit_required", audit_in_memory)
+ with TestClient(app) as test_client:
+ test_client.post("/api/auth/dev-token")
+ hub.store = MemorySessionStore()
+ yield test_client
+
+
+def _wait(predicate, timeout: float = 3.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ value = predicate()
+ if value:
+ return value
+ time.sleep(0.02)
+ raise AssertionError("не дождались")
+
+
+def test_paused_socket_drops_audio_instead_of_feeding_voice(http_client):
+ """Регрессия: пауза блокировала только JSON-команды — бинарные аудиокадры
+ /ws/call всё равно доходили до счётчика и голосового контура."""
+ session_id = uuid4()
+ with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
+ control.send_json({
+ "type": "scenario.start", "scenario_id": "fire-apartment-l2",
+ "trainee": "Иванов И.И.", "mode": "training",
+ })
+ _wait(lambda: hub.get(session_id))
+ state = hub.get(session_id)
+
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ trainee.send_bytes(b"\x00\x00" * 320)
+ _wait(lambda: state.audio_frames == 1)
+
+ control.send_json({"type": "session.pause"})
+ _wait(lambda: state.paused)
+
+ trainee.send_bytes(b"\x00\x00" * 320)
+ trainee.send_bytes(b"\x00\x00" * 320)
+ time.sleep(0.1) # кадры паузы не должны просочиться в счётчик
+
+ assert state.audio_frames == 1
+
+
+def test_reconnect_to_call_replays_paused_banner(http_client):
+ """Регрессия: переподключение к /ws/call не сообщало о текущей паузе —
+ баннер появлялся только после следующего `session.pause`."""
+ session_id = uuid4()
+ with http_client.websocket_connect(f"/ws/control/{session_id}") as control:
+ control.send_json({
+ "type": "scenario.start", "scenario_id": "fire-apartment-l2",
+ "trainee": "Иванов И.И.", "mode": "training",
+ })
+ _wait(lambda: hub.get(session_id))
+ control.send_json({"type": "session.pause"})
+ _wait(lambda: hub.get(session_id).paused)
+
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ messages = [trainee.receive_json() for _ in range(2)]
+ assert any(
+ message["type"] == "session.paused" and message["paused"]
+ for message in messages
+ )
From 88bb54fc24fdc4c7786ef65c71ddbd554557b60a Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 18:06:49 +0300
Subject: [PATCH 08/41] =?UTF-8?q?lct-39:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0?=
=?UTF-8?q?=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8=D0=B2?=
=?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8?=
=?UTF-8?q?=D1=8F=20=D0=B8=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 5 +-
backend/app/api/ws/call.py | 10 +-
backend/app/session/finish.py | 12 ++-
backend/app/session/state.py | 2 +
backend/app/voice/pipeline.py | 56 ++++++++---
backend/app/voice/vad.py | 11 +++
backend/tests/test_session_pause.py | 109 +++++++++++++++++++++-
frontend/src/features/call/useCall.ts | 62 +++++++++---
frontend/src/features/debrief/Debrief.tsx | 2 +
frontend/src/pages/dds/Dds.tsx | 6 +-
frontend/src/pages/trainee/Call.tsx | 23 ++---
frontend/src/styles.css | 1 +
12 files changed, 252 insertions(+), 47 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index 89a4ee2..e6d3569 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -228,7 +228,10 @@ async def active(
states[state.session_id] = state
for state in states.values():
- elapsed = (max(0, int((now - state.started_at).total_seconds()))
+ paused_ms = state.total_paused_ms
+ if state.paused and state.paused_at is not None:
+ paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
+ elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None
queue = station.queue_cards if station else []
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index a8a3c48..0ef1cee 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -235,6 +235,9 @@ async def _handle(session_id: UUID, state, event) -> None:
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
))
return
+ if state.paused:
+ # Ответ модели мог закончиться уже после команды преподавателя.
+ return
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
hub.to_trainee(session_id, CallerUtterance(
utterance_id=uuid4(), text=line.text,
@@ -399,10 +402,9 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
_who, state = entered
with hub.trainee(session_id) as queue:
- if state.paused:
- # Переподключение не ждёт следующего `session.pause`: баннер должен
- # быть на экране сразу, раз сервер уже отклоняет команды (lct-39).
- hub.to_trainee(session_id, SessionPaused(paused=True))
+ # Каждый новый канал получает серверное состояние, включая снятую паузу:
+ # клиент мог потерять событие resume во время переподключения.
+ queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 7d7f64a..5d3dd69 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -12,7 +12,7 @@ import logging
import time
from uuid import UUID
-from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
+from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
from app.domain.statuses import ServiceStatus, current
from app.domain.timers import TimerCode
from app.scoring.ai_coach import coach
@@ -27,6 +27,7 @@ from app.session.score import ScoringSnapshot, score_session, scoring_scenario
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
from app.session.store import (
LessonEnded,
+ LessonResumed,
ScoreArchived,
ScoreCalculated,
ScoreOverridden,
@@ -181,7 +182,16 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
"""
if state.ended:
return
+ was_paused = state.paused
+ paused_ms_before = state.total_paused_ms
ended_at = state.end(reason)
+ if was_paused:
+ hub.record(session_id, LessonResumed(
+ at=ended_at, author="система", role="system",
+ paused_ms=state.total_paused_ms - paused_ms_before,
+ ))
+ hub.broadcast(session_id, SessionPaused(paused=False))
+ hub.to_station(session_id, SessionPaused(paused=False))
if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов.
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 51bd24d..d7f1862 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -197,6 +197,8 @@ class SessionState(PersistedSession):
card.timers.pause(now)
self.paused = True
self.paused_at = now_utc()
+ if self.voice is not None:
+ self.voice.pause()
def resume(self) -> None:
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
diff --git a/backend/app/voice/pipeline.py b/backend/app/voice/pipeline.py
index b8a060c..ea11cb8 100644
--- a/backend/app/voice/pipeline.py
+++ b/backend/app/voice/pipeline.py
@@ -86,6 +86,8 @@ class VoiceSession:
# ── вход: кадры микрофона ──
def feed(self, frame: bytes) -> None:
+ if self.state.paused:
+ return
for event in self._vad.push(frame):
if isinstance(event, SpeechStarted) and self.speaking:
self.barge_in()
@@ -104,6 +106,8 @@ class VoiceSession:
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
чаще всего, — не гасилась вовсе.
"""
+ if self.state.paused:
+ return asyncio.create_task(asyncio.sleep(0))
if self.speaking:
self._reply.cancel()
self._reply = asyncio.create_task(self.say(text, mood))
@@ -119,6 +123,17 @@ class VoiceSession:
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
log.info("сессия %s: перебивание", self.session_id)
+ def pause(self) -> None:
+ """Прервать текущую реплику и убрать речь, накопленную до паузы."""
+ if self.speaking:
+ self._reply.cancel()
+ if self._utterance_id is not None:
+ self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
+ self._utterance_id = None
+ while not self._queue.empty():
+ self._queue.get_nowait()
+ self._vad.reset()
+
async def close(self) -> None:
for task in (self._reply, self._worker):
if task is not None:
@@ -129,6 +144,8 @@ class VoiceSession:
async def _work(self) -> None:
while True:
audio, ended_at = await self._queue.get()
+ if self.state.paused:
+ continue
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
if self.speaking:
self.barge_in()
@@ -143,6 +160,8 @@ class VoiceSession:
timing = TurnTiming()
started = time.monotonic()
text = await self.models.transcribe(audio)
+ if self.state.paused:
+ return
timing.stt_ms = (time.monotonic() - started) * 1000
if not text:
return
@@ -155,6 +174,8 @@ class VoiceSession:
started = time.monotonic()
line = await self._caller_line(text)
+ if self.state.paused:
+ return
timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
@@ -171,6 +192,8 @@ class VoiceSession:
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
) -> None:
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
+ if self.state.paused:
+ return
self._utterance_id = utterance_id = uuid4()
entry = self.state.append(Speaker.CALLER, text, mood)
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
@@ -186,6 +209,8 @@ class VoiceSession:
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
else:
pcm = await self.synthesize(sentence)
+ if self.state.paused:
+ return
if index == 0 and timing is not None:
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
if not pcm:
@@ -205,6 +230,7 @@ class VoiceSession:
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
self.send_event(TtsEnd(utterance_id=utterance_id))
+ self._utterance_id = None
async def _first_sentence(
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
@@ -212,17 +238,25 @@ class VoiceSession:
"""Первое предложение с филлером: если к секунде после конца фразы звука
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
synthesis = asyncio.ensure_future(self.synthesize(sentence))
- remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
- if remaining > 0:
- done, _ = await asyncio.wait({synthesis}, timeout=remaining)
- if done:
- return synthesis.result()
- filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
- if filler:
- self.send_audio(filler)
- if timing is not None:
- timing.filler = True
- return await synthesis
+ try:
+ remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
+ if remaining > 0:
+ done, _ = await asyncio.wait({synthesis}, timeout=remaining)
+ if done:
+ return synthesis.result()
+ filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
+ if filler and not self.state.paused:
+ self.send_audio(filler)
+ if timing is not None:
+ timing.filler = True
+ return await synthesis
+ finally:
+ if not synthesis.done():
+ synthesis.cancel()
+ try:
+ await synthesis
+ except asyncio.CancelledError:
+ pass
async def synthesize(self, text: str) -> bytes:
return await cached_synthesize(self.models, text)
diff --git a/backend/app/voice/vad.py b/backend/app/voice/vad.py
index a5561f1..17fcced 100644
--- a/backend/app/voice/vad.py
+++ b/backend/app/voice/vad.py
@@ -71,6 +71,17 @@ class StreamingVad:
def in_speech(self) -> bool:
return self._in_speech
+ def reset(self) -> None:
+ """Не склеивать фрагменты речи до и после паузы занятия."""
+ self._state.fill(0)
+ self._context.fill(0)
+ self._pending = np.zeros(0, dtype=np.float32)
+ self._preroll.clear()
+ self._speech.clear()
+ self._voiced_ms = 0
+ self._silence_ms = 0
+ self._in_speech = False
+
def _probability(self, window: np.ndarray) -> float:
frame = np.concatenate([self._context, window])[None, :]
output, self._state = self._session.run(
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
index 602889a..50d9c66 100644
--- a/backend/tests/test_session_pause.py
+++ b/backend/tests/test_session_pause.py
@@ -9,6 +9,7 @@ import asyncio
import time
from datetime import timedelta
from pathlib import Path
+from types import SimpleNamespace
from uuid import uuid4
import pytest
@@ -16,7 +17,8 @@ from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws
-from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode
+from app.api.ws import call as call_ws
+from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode, SessionPaused, TtsCancel, TtsEnd
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.main import app
@@ -163,6 +165,12 @@ def test_checkpoint_round_trip_keeps_pause_without_adding_downtime(clock):
assert restored_timer.elapsed_ms == card.timers.timers[TimerCode.DDS_ACK].elapsed_ms
assert restored_timer.started_at is None
+ # После takeover пауза всё ещё действует; лишь явное возобновление
+ # продолжает норматив с пяти секунд до прерывания.
+ restored.resume()
+ clock.advance(10)
+ assert restored_timer.current_ms(clock.now) == 15_000
+
def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
@@ -250,15 +258,30 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
idle = timedelta(seconds=45)
monkeypatch.setattr("app.session.state.now_utc", lambda: base + idle)
+ events = []
+
async def run():
- async with hub.operation(state.session_id):
- await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
+ with hub.trainee(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await end_session(state.session_id, state, CallEndReason.INSTRUCTOR)
+ async with hub.operation(state.session_id):
+ await call_ws._handle(
+ state.session_id, state,
+ SimpleNamespace(type="self_assessment.submit", missed=[], comment="Проверил"),
+ )
+ while not queue.empty():
+ events.append(queue.get_nowait())
asyncio.run(run())
assert state.ended
assert state.paused is False
assert state.total_paused_ms >= 30_000
+ assert state.self_assessed
+ assert state.score is not None
+ assert state.score["full_report"]["total_paused_ms"] >= 30_000
+ assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
+ assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
def test_dds_work_overdue_survives_pause():
@@ -280,6 +303,27 @@ def test_dds_work_overdue_survives_pause():
assert overdue is True
+def test_active_registry_keeps_existing_overdue_during_pause(http_client, clock):
+ app.dependency_overrides[sessions_http.optional_session] = lambda: None
+ state, card = _dds_state_with_active_card(clock)
+ state.owner_login = "dev"
+ timer = card.timers.timers.setdefault(TimerCode.DDS_WORK, Timer(code=TimerCode.DDS_WORK))
+ card.timers.limits[TimerCode.DDS_WORK] = 1_000
+ timer.start(clock.now)
+ clock.advance(5)
+ state.pause()
+ hub._sessions[state.session_id] = state
+ try:
+ response = http_client.get("/api/sessions/active")
+ assert response.status_code == 200
+ row = next(item for item in response.json() if item["session_id"] == str(state.session_id))
+ assert row["dds_work_overdue_cards"] == 1
+ assert row["paused"] is True
+ finally:
+ hub._sessions.pop(state.session_id, None)
+ app.dependency_overrides.pop(sessions_http.optional_session, None)
+
+
@pytest.fixture
def http_client(monkeypatch):
async def audit_in_memory(*_args, **_kwargs):
@@ -347,3 +391,62 @@ def test_reconnect_to_call_replays_paused_banner(http_client):
message["type"] == "session.paused" and message["paused"]
for message in messages
)
+
+ control.send_json({"type": "session.resume"})
+ _wait(lambda: not hub.get(session_id).paused)
+ with http_client.websocket_connect(f"/ws/call/{session_id}") as trainee:
+ message = trainee.receive_json()
+ assert message == {"type": "session.paused", "paused": False}
+
+
+@pytest.mark.asyncio
+async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
+ from app.voice import pipeline
+
+ class FakeVad:
+ def __init__(self, *_args, **_kwargs):
+ self.reset_count = 0
+
+ def reset(self):
+ self.reset_count += 1
+
+ def push(self, _frame):
+ return []
+
+ monkeypatch.setattr(pipeline, "StreamingVad", FakeVad)
+ state, _card = _dds_state_with_active_card(lambda: 1_000.0)
+ events = []
+ audio = []
+ started = asyncio.Event()
+ release = asyncio.Event()
+
+ class Models:
+ vad_path = None
+
+ voice = pipeline.VoiceSession(
+ state.session_id, state, Models(), events.append, lambda _event: None, audio.append,
+ )
+ state.voice = voice
+
+ async def slow_synthesis(_text):
+ started.set()
+ await release.wait()
+ return b"\x00\x00" * 2_400
+
+ monkeypatch.setattr(voice, "synthesize", slow_synthesis)
+ try:
+ speaking = voice.speak("Алло, помогите!", state.persona.mood if state.persona else pipeline.Mood.PANIC)
+ await asyncio.wait_for(started.wait(), timeout=1)
+ voice._queue.put_nowait((b"old", time.monotonic()))
+ state.pause()
+ release.set()
+ with pytest.raises(asyncio.CancelledError):
+ await speaking
+ await asyncio.sleep(0)
+ assert any(isinstance(event, TtsCancel) and event.reason == "director" for event in events)
+ assert not any(isinstance(event, TtsEnd) for event in events)
+ assert audio == []
+ assert voice._queue.empty()
+ assert voice._vad.reset_count == 1
+ finally:
+ await voice.close()
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index 01f01dc..c61caae 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -59,6 +59,7 @@ export function useCall(sessionId: string | null) {
const outbox = useRef>({});
const patchTimer = useRef | null>(null);
const textMode = useRef(false);
+ const pausedRef = useRef(false);
const append = useCallback((line: Line) => {
setLines((prev) => {
@@ -72,19 +73,10 @@ export function useCall(sessionId: string | null) {
const ch = callChannel(sessionId, {
onStatus: (next) => {
setStatus(next);
- if (next === "open") {
- // Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
- // отмечено pending и не пришло эхом от сервера, повторяем после
- // reconnect: kio.patch идемпотентен и не создаёт двойных действий.
- queueMicrotask(() => setCard((current) => {
- if (Object.keys(current.pending).length) {
- channel.current?.send({ type: "kio.patch", fields: current.pending });
- }
- return current;
- }));
- }
},
- onBinary: (frame) => audio.current?.playback.enqueue(frame),
+ onBinary: (frame) => {
+ if (!pausedRef.current) audio.current?.playback.enqueue(frame);
+ },
onEvent: (event: ServerToTrainee) => {
if (event.type === "text.turn.accepted") {
append({ speaker: "operator", text: event.text });
@@ -155,7 +147,27 @@ export function useCall(sessionId: string | null) {
setScoreReady(true);
break;
case "session.paused":
+ pausedRef.current = event.paused;
setPaused(event.paused);
+ if (event.paused) {
+ audio.current?.playback.flush();
+ setCallerSpeaking(false);
+ if (patchTimer.current) clearTimeout(patchTimer.current);
+ patchTimer.current = null;
+ } else {
+ // При переподключении сервер первым присылает состояние паузы.
+ // Только после этого повторяем правки, ожидающие подтверждения.
+ queueMicrotask(() => setCard((current) => {
+ if (Object.keys(current.pending).length) {
+ channel.current?.send({ type: "kio.patch", fields: current.pending });
+ }
+ if (Object.keys(outbox.current).length) {
+ channel.current?.send({ type: "kio.patch", fields: outbox.current });
+ outbox.current = {};
+ }
+ return current;
+ }));
+ }
break;
case "error":
setError(event.message);
@@ -174,13 +186,15 @@ export function useCall(sessionId: string | null) {
}, [sessionId, append]);
const answer = useCallback(async () => {
+ if (pausedRef.current) return;
try {
setError(null);
// Контекст создаётся по нажатию: браузер не даёт играть звук без действия человека.
const context = new AudioContext({ sampleRate: CALLER_RATE });
audio.current = { context, playback: new Playback(context), ambience: new Ambience(context) };
- capture.current = await startCapture((frame) => {
+ const startedCapture = await startCapture((frame) => {
+ if (pausedRef.current) return;
const playback = audio.current?.playback;
// Перебивание: фронт гасит звук мгновенно, сервер подтвердит `tts.cancel`.
if (gate.current.push(frame) && playback?.speaking) {
@@ -189,6 +203,13 @@ export function useCall(sessionId: string | null) {
}
channel.current?.sendBinary(frame);
});
+ if (pausedRef.current) {
+ await startedCapture.stop();
+ await context.close();
+ audio.current = null;
+ return;
+ }
+ capture.current = startedCapture;
setMicOn(true);
channel.current?.send({ type: "call.answer" });
} catch (err) {
@@ -197,36 +218,45 @@ export function useCall(sessionId: string | null) {
}, []);
const hangup = useCallback(() => {
+ if (pausedRef.current) return;
channel.current?.send({ type: "call.hangup" });
void capture.current?.stop();
capture.current = null;
setMicOn(false);
}, []);
- const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
+ const hint = useCallback(() => {
+ if (!pausedRef.current) channel.current?.send({ type: "hint.request" });
+ }, []);
const patchKio = useCallback((path: string, value: unknown) => {
+ if (pausedRef.current) return;
setCard((prev) => edit(prev, path, value));
outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => {
const fields = outbox.current;
outbox.current = {};
- if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
+ if (pausedRef.current) {
+ outbox.current = { ...fields, ...outbox.current };
+ } else if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service?: DDSCode) => {
+ if (pausedRef.current) return;
channel.current?.send(service ? { type: "dds.dispatch", service } : { type: "dds.dispatch" });
}, []);
const sendText = useCallback((text: string) => {
+ if (pausedRef.current) return false;
const clean = text.trim();
if (!clean || clean.length > 1000) return false;
return Boolean(channel.current?.send({ type: "text.turn", text: clean }));
}, []);
const submitCard = useCallback(() => {
+ if (pausedRef.current) return;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
const fields = outbox.current;
@@ -247,10 +277,12 @@ export function useCall(sessionId: string | null) {
* Отдельное действие, а не «положил трубку»: система должна отличить
* осознанное решение от брошенного вызова (docs/spec/TICKETS.md). */
const resolve = useCallback((outcome: Outcome, comment: string) => {
+ if (pausedRef.current) return;
channel.current?.send({ type: "call.resolve", outcome, comment });
}, []);
const submitSelfAssessment = useCallback((missed: string[], comment: string) => {
+ if (pausedRef.current) return;
channel.current?.send({ type: "self_assessment.submit", missed, comment });
setSelfAssessed(true);
}, []);
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 2398064..f23dd8a 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -20,6 +20,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
+ const pausedSeconds = Math.round((report.total_paused_ms ?? 0) / 1000);
return (
@@ -43,6 +44,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}
+ Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
Служебные сведения
Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index b84a14a..068bd7a 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -321,7 +321,7 @@ export function Dds() {
return () => clearInterval(timer);
}, []);
- const send = (command: StationToServer): boolean => channel.current?.send(command) ?? false;
+ const send = (command: StationToServer): boolean => !paused && (channel.current?.send(command) ?? false);
if (!sessionId) return ;
const reports = station?.phone_reports ?? [];
@@ -331,6 +331,7 @@ export function Dds() {
const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
const isConnected = status === "open";
+ const pausedSeconds = Math.round((report?.total_paused_ms ?? 0) / 1000);
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1;
@@ -378,6 +379,7 @@ export function Dds() {
{report ?
Итоговая оценка: {report.score_final.toFixed(0)} из 100
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}
+
Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
Скачать отчёт: CSV
{" · "}PDF
{Boolean(report.card_results?.length) && <>
@@ -413,6 +415,7 @@ export function Dds() {
}
}
+
{view === "registry" && <>
@@ -707,6 +710,7 @@ export function Dds() {
}
+
;
}
diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx
index a1a9c2c..2bbf514 100644
--- a/frontend/src/pages/trainee/Call.tsx
+++ b/frontend/src/pages/trainee/Call.tsx
@@ -122,16 +122,16 @@ export function Call() {
event.preventDefault();
if (call.sendText(textTurn)) setTextTurn("");
}}>
- setTextTurn(event.target.value)} />
- Отправить
+ Отправить
{call.paused && Пауза, ждите преподавателя
}
{call.error && {call.error}
}
+ readOnly={call.paused || call.cardSubmitted} referenceArm onChange={call.patchKio} />
@@ -143,7 +143,7 @@ export function Call() {
? notifiedServices.map((service) => {service} )
: появятся после выбора признаков }
- {!call.cardSubmitted && Сохранить и проверить }
{call.cardSubmitted && {call.briefing.handoff_to_dds ? "Карточка передана ДДС" : "Карточка отправлена"} }
@@ -175,11 +175,11 @@ export function Call() {
- {(call.phase === "incoming" || call.phase === "talking") && !call.micOn && void call.answer()}>
+ {(call.phase === "incoming" || call.phase === "talking") && !call.micOn && void call.answer()}>
{call.phase === "talking" ? "☎ Продолжить разговор" : "☎ Ответить на вызов"}
}
{call.micOn && ● Разговор идёт }
- {call.micOn && hintsAllowed(call.incoming?.mode) && Подсказка }
+ {call.micOn && hintsAllowed(call.incoming?.mode) && Подсказка }
{call.phase === "ended"
? "Вызов завершён"
: !call.micOn && call.phase === "incoming"
@@ -198,7 +198,7 @@ export function Call() {
@@ -209,12 +209,12 @@ export function Call() {
call.dispatch(service)}
>
Передать в ДДС
- {call.micOn && Завершить вызов }
+ {call.micOn && Завершить вызов }
@@ -227,6 +227,7 @@ export function Call() {
type="text"
aria-label="Комментарий при закрытии вызова без карточки"
value={resolveComment}
+ disabled={call.paused}
placeholder="что сказали заявителю, куда передали"
onChange={(event) => setResolveComment(event.target.value)}
/>
@@ -234,7 +235,7 @@ export function Call() {
{
call.resolve("consultation", resolveComment);
setResolved("consultation");
@@ -244,7 +245,7 @@ export function Call() {
{" "}
{
call.resolve("transfer_region", resolveComment);
setResolved("transfer_region");
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 857e334..4990006 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -316,6 +316,7 @@ button:disabled { opacity: 0.5; cursor: default; }
/* АРМ ДДС: реестр остаётся плотной таблицей, карточка и связь — рядом. */
.dds-workspace { max-width: 1800px; margin: 0 auto; }
+.dds-pause-controls { display: contents; border: 0; margin: 0; padding: 0; min-width: 0; }
.dds-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 8px 0 16px; }
.dds-header h1 { margin: 2px 0 0; }
.dds-header h1 span { color: #576273; font-size: 15px; font-weight: 400; }
From a35aa3f6a21d0dd5341dcc94b68b56d3ef3c3407 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 18:11:51 +0300
Subject: [PATCH 09/41] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?=
=?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=BD=D0=B0=20?=
=?UTF-8?q?=D0=B2=D1=82=D0=BE=D1=80=D0=BE=D0=BC=20=D1=83=D0=B7=D0=BB=D0=B5?=
=?UTF-8?q?=20=D0=BE=D1=82=20=D0=B3=D0=BE=D0=BD=D0=BE=D0=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/auth.py | 40 ++++++++--------
backend/tests/test_auth_hardening.py | 70 ++++++++++++++++++++++++++++
scripts/test_cluster_failover.py | 23 +++++++--
3 files changed, 110 insertions(+), 23 deletions(-)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index 291350c..f6e1941 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -56,14 +56,13 @@ _vanished: set[str] = set()
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
-# One lock per login this node has not synced yet, so a concurrent HTTP
-# request and WS handshake for the same just-migrated login collapse into a
-# single SELECT instead of one each (lct-42).
+# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
+# разделяют один запрос к БД до очередной синхронизации (lct-42).
_lookup_locks: dict[str, asyncio.Lock] = {}
class _AuthStateUnavailable(Exception):
- """The one-shot lookup for a login unknown to this node could not reach PostgreSQL."""
+ """Разовая проверка неизвестного узлу логина не смогла обратиться к БД."""
async def _close_revoked(ws: WebSocket) -> None:
@@ -116,15 +115,18 @@ async def load_generations() -> None:
async def sync_generations() -> None:
- """Refresh shared account epochs and close sockets revoked on peer nodes."""
+ """Сверить версии полномочий и закрыть отозванные на другом узле сокеты."""
+ known_before_query = set(_generations)
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
current = {login: version for login, version in rows}
for login, version in current.items():
previous = _generations.get(login)
+ # Снимок БД мог устареть за время запроса; меньшая версия не должна
+ # отменять локальный отзыв, который уже поднял поколение.
if previous is None:
_generations[login] = version
- elif previous != version:
+ elif version > previous:
invalidate_login(login, version)
# Account deletion is not exposed by the application. Still close active
# sockets if an operator removes one directly from the shared directory DB.
@@ -135,7 +137,9 @@ async def sync_generations() -> None:
# lookup fall back to 0 and re-accept cookies issued before the revocation.
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
_vanished.intersection_update(_generations.keys() - current.keys())
- for login in _generations.keys() - current.keys() - synthetic - _vanished:
+ # Разовая проверка могла найти учётку после снимка этого запроса.
+ # Её отсутствие в старом снимке не означает отзыв.
+ for login in known_before_query - current.keys() - synthetic - _vanished:
invalidate_login(login)
_vanished.add(login)
global _generations_synced_at
@@ -143,32 +147,28 @@ async def sync_generations() -> None:
async def _resolve_unknown_login(login: str) -> int | None:
- """Look up a login absent from this node's cache without waiting a tick.
+ """Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
- A revoked login stays cached in `_generations` (see `_vanished` above),
- so absence from `_generations` unambiguously means "this node has not
- synced this login yet" — an unrecognized login is checked directly
- rather than treated as revoked. Returns the current epoch, or `None` if
- the login is not (or no longer) in `users`. Raises
- `_AuthStateUnavailable` if PostgreSQL cannot be reached; the caller
- fails closed.
+ Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
+ отсутствие в кэше означает, что этот узел ещё не видел учётку.
+ Возвращает версию или None, если строки в `users` нет. При недоступной
+ БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт с 503/1013.
"""
lock = _lookup_locks.setdefault(login, asyncio.Lock())
async with lock:
cached = _generations.get(login)
if cached is not None:
- return cached # a concurrent request already resolved it
+ return cached # параллельный запрос уже получил версию
try:
async with get_sessionmaker()() as db:
version = await db.scalar(
select(User.auth_version).where(User.login == login)
)
- except Exception as exc: # noqa: BLE001 — fail closed, not FORBIDDEN
+ except Exception as exc: # noqa: BLE001 — ошибка БД должна закрыть вход
log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
raise _AuthStateUnavailable from exc
- # A concurrent local revoke or sync tick may have written a newer
- # generation while the SELECT above was in flight; the stale read
- # must never clobber it back to an older, already-revoked epoch.
+ # Пока шёл SELECT, локальный отзыв или синхронизация могли записать
+ # новую версию. Старый ответ не должен вернуть отозванную cookie.
cached = _generations.get(login)
if cached is not None:
return cached
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index d39c759..e484462 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -135,6 +135,76 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
asyncio.run(run())
+@pytest.mark.asyncio
+async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
+ snapshot_read = asyncio.Event()
+ release_snapshot = asyncio.Event()
+
+ class FakeResult:
+ def all(self):
+ return []
+
+ class FakeDb:
+ async def execute(self, _query):
+ snapshot_read.set()
+ await release_snapshot.wait()
+ return FakeResult()
+
+ async def scalar(self, _query):
+ return 0
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ auth.prime_generations({})
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ sync = asyncio.create_task(auth.sync_generations())
+ await snapshot_read.wait()
+ assert await auth._resolve_unknown_login("just-created") == 0
+ release_snapshot.set()
+ await sync
+ assert auth._generations["just-created"] == 0
+ assert "just-created" not in auth._vanished
+
+
+@pytest.mark.asyncio
+async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
+ snapshot_read = asyncio.Event()
+ release_snapshot = asyncio.Event()
+
+ class FakeResult:
+ def all(self):
+ return [("revoked", 0)]
+
+ class FakeDb:
+ async def execute(self, _query):
+ snapshot_read.set()
+ await release_snapshot.wait()
+ return FakeResult()
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ auth.prime_generations({"revoked": 0})
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ sync = asyncio.create_task(auth.sync_generations())
+ await snapshot_read.wait()
+ auth.invalidate_login("revoked", 1)
+ release_snapshot.set()
+ await sync
+ assert auth._generations["revoked"] == 1
+
+
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
assert client.post("/api/auth/dev-token").status_code == 200
with pytest.raises(WebSocketDisconnect) as exc:
diff --git a/scripts/test_cluster_failover.py b/scripts/test_cluster_failover.py
index 0ececc8..a907266 100644
--- a/scripts/test_cluster_failover.py
+++ b/scripts/test_cluster_failover.py
@@ -72,7 +72,7 @@ def login(base: str, login_name: str, password: str) -> str:
return cookie
-async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> None:
+async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> float:
async with websockets.connect(
f"{ws_base}/ws/control/{session_id}",
additional_headers={"Cookie": cookie},
@@ -81,7 +81,9 @@ async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dic
ping_interval=None,
) as socket:
await socket.send(json.dumps(payload, ensure_ascii=False))
+ sent_at = time.monotonic()
await asyncio.sleep(0.15)
+ return sent_at
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
@@ -220,7 +222,7 @@ async def main(args: argparse.Namespace) -> int:
factory = async_sessionmaker(engine, expire_on_commit=False)
observer_engine = create_async_engine(database_url, poolclass=NullPool)
run_id = uuid4().hex[:12]
- session_id = uuid4()
+ session_id = args.session_id or uuid4()
trainee = Trainee(name=f"Failover smoke {run_id}")
instructor_login = f"fo-i-{run_id}"
trainee_login = f"fo-t-{run_id}"
@@ -232,6 +234,7 @@ async def main(args: argparse.Namespace) -> int:
fault_stopped = False
result: dict = {"session_id": str(session_id), "checks": {}}
backend = f"http://127.0.0.1:{args.backend_port}"
+ result["login_node"] = "backend-a"
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
instructor_cookie = trainee_cookie = None
try:
@@ -249,12 +252,18 @@ async def main(args: argparse.Namespace) -> int:
await db.commit()
instructor_cookie = login(backend, instructor_login, instructor_password)
+ login_completed_at = time.monotonic()
trainee_cookie = login(backend, trainee_login, trainee_password)
- await send_control(ws_base, session_id, instructor_cookie, {
+ control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
"mode": "training", "exercise": "dds",
})
+ result["login_to_control_seconds"] = round(control_sent_at - login_completed_at, 4)
+ if args.expect_initial_owner == "backend-b":
+ result["checks"]["control_sent_within_first_second"] = (
+ result["login_to_control_seconds"] < 1
+ )
async def session_row():
async with factory() as db:
@@ -289,6 +298,12 @@ async def main(args: argparse.Namespace) -> int:
old_epoch = row.backend_fencing_epoch
result["initial_owner"] = row.backend_node_id
result["initial_epoch"] = old_epoch
+ if args.expect_initial_owner and row.backend_node_id != args.expect_initial_owner:
+ raise RuntimeError(
+ f"session routed to {row.backend_node_id}, expected {args.expect_initial_owner}"
+ )
+ if args.expect_initial_owner == "backend-b":
+ result["checks"]["login_a_initial_control_b"] = True
# Commit real DDS work before the fault. These actions must survive the
# checkpoint handoff and remain part of the final scored report.
@@ -642,4 +657,6 @@ if __name__ == "__main__":
parser.add_argument("--reconnect-attempts", type=int, default=5)
parser.add_argument("--rest-timeout", type=float, default=60)
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
+ parser.add_argument("--expect-initial-owner", choices=("backend-a", "backend-b"))
+ parser.add_argument("--session-id", type=UUID)
raise SystemExit(asyncio.run(main(parser.parse_args())))
From c26c8ba33272fa09b2d3c59008227ea4776f8932 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:23:12 +0300
Subject: [PATCH 10/41] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20?=
=?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=BB=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=20=D0=B4?=
=?UTF-8?q?=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B8=D0=B2=D1=8B=20=D0=BF=D1=83?=
=?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=B2=D1=82?=
=?UTF-8?q?=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?=
=?UTF-8?q?=D0=BA=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80=D0=B0,=20=D1=81?=
=?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE?=
=?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B0=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/ws/control.py | 8 ++
backend/app/session/finish.py | 7 +-
backend/app/session/timers.py | 4 +
backend/tests/test_session_pause.py | 213 ++++++++++++++++++++++++++--
4 files changed, 220 insertions(+), 12 deletions(-)
diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py
index 367c3d7..82656a9 100644
--- a/backend/app/api/ws/control.py
+++ b/backend/app/api/ws/control.py
@@ -418,6 +418,14 @@ async def _command(session_id: UUID, event, who) -> None:
case "director.inject":
if state is None:
return
+ if state.paused:
+ # Обрыв на паузе запустил бы норматив обратного дозвона, и простой
+ # ушёл бы в него; курсант же за баннером паузы не может ответить.
+ hub.to_observers(session_id, ErrorEvent(
+ code=ErrorKind.UNSUPPORTED_EVENT,
+ message="Занятие на паузе",
+ ))
+ return
if state.exercise is not Exercise.CALL:
hub.to_observers(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT,
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 5d3dd69..a8b22df 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -190,8 +190,6 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
at=ended_at, author="система", role="system",
paused_ms=state.total_paused_ms - paused_ms_before,
))
- hub.broadcast(session_id, SessionPaused(paused=False))
- hub.to_station(session_id, SessionPaused(paused=False))
if state.exercise is Exercise.CARD and state.dispatched_card is None:
state.on_event("card.end")
# Норматив отработки фиксируется событием, а не текущим значением часов.
@@ -210,6 +208,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
hub.to_station(session_id, SessionEnded(reason=reason))
else:
hub.to_trainee(session_id, CallEnded(reason=reason))
+ if was_paused:
+ # Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
+ # на паузе правки карточки в уже закрытое занятие.
+ hub.broadcast(session_id, SessionPaused(paused=False))
+ hub.to_station(session_id, SessionPaused(paused=False))
if state.score is None:
return
hub.to_observers(session_id, ScoreReady(session_id=session_id))
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index c8a4835..f183c7b 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -113,6 +113,10 @@ class Timer(BaseModel):
return data
def start(self, now: float) -> None:
+ if self.paused:
+ # Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
+ # набранное время, а ход до `resume` посчитал бы паузу.
+ return
if self.stopped:
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
self.attempt += 1
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
index 50d9c66..cf522a4 100644
--- a/backend/tests/test_session_pause.py
+++ b/backend/tests/test_session_pause.py
@@ -9,7 +9,6 @@ import asyncio
import time
from datetime import timedelta
from pathlib import Path
-from types import SimpleNamespace
from uuid import uuid4
import pytest
@@ -18,7 +17,30 @@ from fastapi.testclient import TestClient
from app.api.http import sessions as sessions_http
from app.api.ws import control as control_ws
from app.api.ws import call as call_ws
-from app.domain.events import CallEndReason, Exercise, LessonCriteria, SessionMode, SessionPaused, TtsCancel, TtsEnd
+from app.api.ws import station as station_ws
+from app.domain.classifiers import DDSCode
+from app.domain.events import (
+ CallbackDial,
+ CallEnded,
+ CallEndReason,
+ CardAck,
+ DdsDispatch,
+ DirectorInject,
+ ErrorEvent,
+ Exercise,
+ HintRequest,
+ KioPatchIn,
+ LessonCriteria,
+ Principal,
+ SelfAssessmentSubmit,
+ SessionMode,
+ SessionPause,
+ SessionPaused,
+ SessionResume,
+ TtsCancel,
+ TtsEnd,
+)
+from app.domain.roles import Role
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.main import app
@@ -33,6 +55,7 @@ from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
from app.session.timers import Timer
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
+TEACHER = Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR)
def _dds_state_with_active_card(clock, criteria_ms: int = 30_000):
@@ -185,17 +208,12 @@ def test_pause_and_resume_are_recorded_for_audit(monkeypatch):
store = MemorySessionStore()
monkeypatch.setitem(hub._sessions, state.session_id, state)
monkeypatch.setattr(hub, "store", store)
- who = type("Who", (), {"login": "teacher", "role": type("R", (), {"value": "instructor"})()})()
async def run():
async with hub.operation(state.session_id):
- await control_ws._command(
- state.session_id, type("E", (), {"type": "session.pause"})(), who,
- )
+ await control_ws._command(state.session_id, SessionPause(), TEACHER)
async with hub.operation(state.session_id):
- await control_ws._command(
- state.session_id, type("E", (), {"type": "session.resume"})(), who,
- )
+ await control_ws._command(state.session_id, SessionResume(), TEACHER)
asyncio.run(run())
@@ -267,7 +285,7 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
async with hub.operation(state.session_id):
await call_ws._handle(
state.session_id, state,
- SimpleNamespace(type="self_assessment.submit", missed=[], comment="Проверил"),
+ SelfAssessmentSubmit(missed=[], comment="Проверил"),
)
while not queue.empty():
events.append(queue.get_nowait())
@@ -281,6 +299,10 @@ def test_ending_session_while_paused_finalizes_pause_and_unblocks_report(clock,
assert state.score is not None
assert state.score["full_report"]["total_paused_ms"] >= 30_000
assert any(isinstance(event, SessionPaused) and not event.paused for event in events)
+ # Снятие паузы приходит после `call.ended`: иначе клиент дошлёт отложенные
+ # на паузе правки в уже закрытое занятие.
+ kinds = [type(event) for event in events]
+ assert kinds.index(CallEnded) < kinds.index(SessionPaused)
assert any(isinstance(record, LessonResumed) for _sid, batch in store.commits for record in batch)
@@ -450,3 +472,174 @@ async def test_pause_cancels_inflight_voice_and_clears_buffer(monkeypatch):
assert voice._vad.reset_count == 1
finally:
await voice.close()
+
+
+def _call_state():
+ scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
+ return SessionState(
+ session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
+ level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
+ scenario=scenario, owner_login="teacher",
+ )
+
+
+def _drain(queue) -> list:
+ events = []
+ while not queue.empty():
+ events.append(queue.get_nowait())
+ return events
+
+
+def test_timer_start_on_pause_keeps_frozen_elapsed(clock):
+ """Повторное событие запуска на паузе не должно пустить часы с нуля."""
+ timer = Timer(code=TimerCode.CALLBACK)
+ timer.start(clock.now)
+ clock.advance(4)
+ timer.pause(clock.now)
+
+ timer.start(clock.now)
+ clock.advance(120)
+
+ assert timer.paused is True
+ assert timer.current_ms(clock.now) == 4_000
+
+
+def test_director_inject_rejected_while_paused(clock, monkeypatch):
+ """Регрессия: обрыв связи на паузе запускал норматив обратного дозвона,
+ и весь простой уходил в него; курсант получал `call.ended` под баннером."""
+ state = _call_state()
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.on_event("call.incoming")
+ state.on_event("call.answer")
+ state.pause()
+
+ async def run():
+ with hub.observer(state.session_id) as observer, hub.trainee(state.session_id) as trainee:
+ async with hub.operation(state.session_id):
+ await control_ws._command(
+ state.session_id, DirectorInject(directive="line_dropped"), TEACHER,
+ )
+ return _drain(observer), _drain(trainee)
+
+ observed, to_trainee = asyncio.run(run())
+ clock.advance(120)
+
+ assert any(isinstance(event, ErrorEvent) for event in observed)
+ assert to_trainee == []
+ assert state.directives == []
+ assert TimerCode.CALLBACK not in state.timers.timers
+ assert state.timers.timers[TimerCode.INTERVIEW].paused is True
+
+
+@pytest.mark.parametrize("event", [
+ KioPatchIn(fields={"address": "ул. Ленина, 1"}),
+ HintRequest(),
+ DdsDispatch(service=DDSCode.FIRE),
+ CallbackDial(),
+], ids=lambda event: event.type)
+def test_call_channel_rejects_trainee_actions_while_paused(clock, monkeypatch, event):
+ """Истина — сервер: кнопки на фронте выключены, но команда с паузы
+ не должна ни менять карточку, ни останавливать норматив опроса."""
+ state = _call_state()
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.on_event("call.incoming")
+ state.on_event("call.answer")
+ clock.advance(5)
+ state.pause()
+ kio_before = state.kio.model_dump()
+
+ async def run():
+ with hub.trainee(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await call_ws._handle(state.session_id, state, event)
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert [type(item) for item in events] == [ErrorEvent]
+ assert state.kio.model_dump() == kio_before
+ interview = state.timers.timers[TimerCode.INTERVIEW]
+ assert interview.paused and not interview.stopped
+ assert TimerCode.DDS_ACK not in state.timers.timers
+ assert TimerCode.CALLBACK not in state.timers.timers
+ assert state.hints_shown == []
+
+
+def test_station_channel_rejects_card_ack_while_paused(clock, monkeypatch):
+ state, card = _dds_state_with_active_card(clock)
+ monkeypatch.setattr(hub, "store", MemorySessionStore())
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ clock.advance(5)
+ state.pause()
+ card_before = card.model_dump()
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await station_ws._handle(state.session_id, state, CardAck(comment="Принято"))
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert [type(item) for item in events] == [ErrorEvent]
+ assert card.model_dump() == card_before
+ ack = card.timers.timers[TimerCode.DDS_ACK]
+ assert ack.paused and not ack.stopped
+ assert ack.current_ms(clock.now) == 5_000
+
+
+def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
+ state, card = _dds_state_with_active_card(clock)
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ base = now_utc()
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base)
+
+ async def command(event):
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, event, TEACHER)
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ clock.advance(5)
+ await command(SessionPause())
+ clock.advance(30)
+ await command(SessionPause()) # второе нажатие не сдвигает начало паузы
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=60))
+ await command(SessionResume())
+ await command(SessionResume())
+ return _drain(queue)
+
+ events = asyncio.run(run())
+ clock.advance(10)
+
+ records = [record for _sid, batch in store.commits for record in batch]
+ assert sum(isinstance(record, LessonPaused) for record in records) == 1
+ assert sum(isinstance(record, LessonResumed) for record in records) == 1
+ assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
+ assert state.total_paused_ms == 60_000
+ assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
+
+
+def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
+ state, _card = _dds_state_with_active_card(clock)
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+ state.end(CallEndReason.INSTRUCTOR)
+
+ async def run():
+ with hub.station(state.session_id) as queue:
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, SessionPause(), TEACHER)
+ return _drain(queue)
+
+ events = asyncio.run(run())
+
+ assert state.paused is False
+ assert state.paused_at is None
+ assert events == []
+ assert not any(isinstance(record, LessonPaused) for _sid, batch in store.commits for record in batch)
From a8d717200d06b36fbdf586359e7626dc3d24dc3c Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:25:01 +0300
Subject: [PATCH 11/41] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B0=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0?=
=?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD=D0=B0=20=D1=81=20=D1=82=D0=B0?=
=?UTF-8?q?=D0=B9=D0=BC=D0=B0=D1=83=D1=82=D0=BE=D0=BC=20=D0=B8=20=D0=BA?=
=?UTF-8?q?=D1=8D=D1=88=D0=B5=D0=BC=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0?=
=?UTF-8?q?,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D1=83=D0=B7=D0=BB?=
=?UTF-8?q?=D0=B0=20=D0=B2=D1=8B=D1=88=D0=B5=20=D0=91=D0=94=20=D0=B7=D0=B0?=
=?UTF-8?q?=D0=BF=D0=B8=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?=
=?UTF-8?q?=D0=B2=20=D0=91=D0=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/auth.py | 142 ++++++++++++++++++++++++++++++++--------
1 file changed, 115 insertions(+), 27 deletions(-)
diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index f6e1941..0570d32 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -28,7 +28,7 @@ from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import APIRouter, HTTPException, Request, WebSocket
from pydantic import BaseModel, Field
-from sqlalchemy import select
+from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from starlette.websockets import WebSocketDisconnect
@@ -57,8 +57,21 @@ AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
-# разделяют один запрос к БД до очередной синхронизации (lct-42).
-_lookup_locks: dict[str, asyncio.Lock] = {}
+# разделяют один запрос к БД до очередной синхронизации (lct-42). Запись
+# удаляется, когда lock больше никто не ждёт: иначе словарь растёт с каждым
+# логином, который узел когда-либо проверял.
+_lookup_locks: dict[str, "_LoginLookup"] = {}
+# Логины, которых разовая проверка не нашла в users. Держатся до следующей
+# сверки: повтор старой cookie не должен давать SELECT на каждый запрос.
+_missing_logins: set[str] = set()
+
+
+class _LoginLookup:
+ __slots__ = ("lock", "users")
+
+ def __init__(self) -> None:
+ self.lock = asyncio.Lock()
+ self.users = 0
class _AuthStateUnavailable(Exception):
@@ -105,6 +118,7 @@ def prime_generations(values: dict[str, int]) -> None:
global _generations_synced_at
_generations.clear()
_generations.update(values)
+ _missing_logins.clear()
_generations_synced_at = time.monotonic()
@@ -119,7 +133,27 @@ async def sync_generations() -> None:
known_before_query = set(_generations)
async with get_sessionmaker()() as db:
rows = (await db.execute(select(User.login, User.auth_version))).all()
- current = {login: version for login, version in rows}
+ current = {login: version for login, version in rows}
+ # Версия узла выше БД, если отзыв не удалось записать (неудачный
+ # logout) или учётку пересоздали после удаления. Без записи в БД
+ # узлы расходятся навсегда: соседний узел выдаёт cookie со старой
+ # версией, а этот её отвергает. Поднимаем БД до версии узла — отзыв
+ # сохраняется, остальные узлы догоняют за одну сверку. При гонке со
+ # снимком БД уже выше, и UPDATE ничего не меняет.
+ ahead = {
+ login: _generations[login]
+ for login, version in current.items()
+ if login in _generations and _generations[login] > version
+ }
+ if ahead:
+ try:
+ for login, local in ahead.items():
+ await db.execute(_raise_auth_version(login, local))
+ await db.commit()
+ except Exception as exc: # noqa: BLE001 — повторим на следующей сверке
+ log.error("не удалось записать версию полномочий узла (%s)",
+ type(exc).__name__)
+ await db.rollback()
for login, version in current.items():
previous = _generations.get(login)
# Снимок БД мог устареть за время запроса; меньшая версия не должна
@@ -142,40 +176,81 @@ async def sync_generations() -> None:
for login in known_before_query - current.keys() - synthetic - _vanished:
invalidate_login(login)
_vanished.add(login)
+ # Снимок только что прочитан: учётка, созданная до него, уже в кэше.
+ _missing_logins.clear()
global _generations_synced_at
_generations_synced_at = time.monotonic()
+def _raise_auth_version(login: str, version: int):
+ # Только вверх: параллельная запись в БД могла уже поднять версию выше.
+ return (
+ update(User)
+ .where(User.login == login, User.auth_version < version)
+ .values(auth_version=version)
+ )
+
+
async def _resolve_unknown_login(login: str) -> int | None:
"""Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
отсутствие в кэше означает, что этот узел ещё не видел учётку.
Возвращает версию или None, если строки в `users` нет. При недоступной
- БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт с 503/1013.
+ или зависшей БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт
+ с 503/1013.
"""
- lock = _lookup_locks.setdefault(login, asyncio.Lock())
- async with lock:
- cached = _generations.get(login)
- if cached is not None:
- return cached # параллельный запрос уже получил версию
- try:
- async with get_sessionmaker()() as db:
- version = await db.scalar(
- select(User.auth_version).where(User.login == login)
+ if login in _missing_logins:
+ return None
+ entry = _lookup_locks.get(login)
+ if entry is None:
+ entry = _lookup_locks[login] = _LoginLookup()
+ entry.users += 1
+ try:
+ # Тот же предел, что у сверки: при partition handshake не ждёт
+ # таймаута TCP, а очередь за lock не растягивает ожидание сверх него.
+ async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
+ async with entry.lock:
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached # параллельный запрос уже получил версию
+ if login in _missing_logins:
+ return None
+ async with get_sessionmaker()() as db:
+ version = await db.scalar(
+ select(User.auth_version).where(User.login == login)
+ )
+ # Пока шёл SELECT, локальный отзыв или синхронизация могли
+ # записать новую версию. Старый ответ не должен вернуть
+ # отозванную cookie.
+ cached = _generations.get(login)
+ if cached is not None:
+ return cached
+ if version is None:
+ _missing_logins.add(login)
+ return None
+ _generations[login] = version
+ log.warning(
+ "учётка %s найдена разовой проверкой до сверки узла",
+ login_log_marker(login),
)
- except Exception as exc: # noqa: BLE001 — ошибка БД должна закрыть вход
- log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
- raise _AuthStateUnavailable from exc
- # Пока шёл SELECT, локальный отзыв или синхронизация могли записать
- # новую версию. Старый ответ не должен вернуть отозванную cookie.
- cached = _generations.get(login)
- if cached is not None:
- return cached
- if version is None:
- return None
- _generations[login] = version
- return version
+ return version
+ except Exception as exc: # noqa: BLE001 — ошибка или таймаут БД закрывают вход
+ log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
+ raise _AuthStateUnavailable from exc
+ finally:
+ entry.users -= 1
+ if entry.users == 0 and _lookup_locks.get(login) is entry:
+ del _lookup_locks[login]
+
+
+def login_log_marker(login: str) -> str:
+ """Метка логина для журнала без самого логина.
+
+ По ней кластерный смоук доказывает, что узел прошёл через разовую
+ проверку, а не увидел учётку обычной сверкой.
+ """
+ return hashlib.sha256(f"lct-login:{login}".encode()).hexdigest()[:12]
async def watch_generations() -> None:
@@ -590,7 +665,20 @@ async def login(payload: LoginIn, request: Request) -> dict:
await audit_required(user.login, user.role, "login.blocked")
raise HTTPException(status_code=403, detail="blocked")
- if _generations.get(user.login) != user.auth_version:
+ local_version = _generations.get(user.login)
+ if local_version is not None and local_version > user.auth_version:
+ # Отзыв на этом узле не дошёл до БД (неудачный logout). Сброс к версии
+ # БД вернул бы силу cookie, выданной до выхода; выдача cookie с
+ # версией узла без записи в БД развела бы узлы. Поднимаем БД.
+ try:
+ async with get_sessionmaker()() as db:
+ await db.execute(_raise_auth_version(user.login, local_version))
+ await db.commit()
+ except Exception as exc: # noqa: BLE001 — cookie без записанной версии не выдаём
+ log.error("вход: версию полномочий не удалось записать (%s)",
+ type(exc).__name__)
+ raise HTTPException(status_code=503, detail="auth_state_unavailable") from exc
+ elif local_version != user.auth_version:
invalidate_login(user.login, user.auth_version)
who = Principal(
From d44dbc8d926ed0e6bdac1783e033ff0e6fc04bba Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:28:02 +0300
Subject: [PATCH 12/41] =?UTF-8?q?test:=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B0?=
=?UTF-8?q?=D1=83=D1=82=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20?=
=?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8,=20=D0=BA?=
=?UTF-8?q?=D1=8D=D1=88=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B0,=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?=
=?UTF-8?q?=D0=B8=20=D1=83=D0=B7=D0=BB=D0=B0=20=D0=B2=20=D0=91=D0=94=20?=
=?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B5=20?=
=?UTF-8?q?=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_auth_hardening.py | 296 ++++++++++++++++++++++++++-
1 file changed, 295 insertions(+), 1 deletion(-)
diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py
index e484462..f116913 100644
--- a/backend/tests/test_auth_hardening.py
+++ b/backend/tests/test_auth_hardening.py
@@ -177,16 +177,24 @@ async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeyp
snapshot_read = asyncio.Event()
release_snapshot = asyncio.Event()
+ raised = []
+
class FakeResult:
def all(self):
return [("revoked", 0)]
class FakeDb:
- async def execute(self, _query):
+ async def execute(self, query):
+ if query.is_update:
+ raised.append(query.compile().params)
+ return None
snapshot_read.set()
await release_snapshot.wait()
return FakeResult()
+ async def commit(self):
+ return None
+
class FakeSession:
async def __aenter__(self):
return FakeDb()
@@ -203,6 +211,151 @@ async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeyp
release_snapshot.set()
await sync
assert auth._generations["revoked"] == 1
+ # В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
+ # в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
+ assert [params["auth_version"] for params in raised] == [1]
+
+
+def _versioned_users_db(versions: dict[str, int]):
+ """Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
+ updates = []
+
+ class FakeResult:
+ def all(self):
+ return list(versions.items())
+
+ class FakeDb:
+ async def execute(self, query):
+ if query.is_update:
+ params = query.compile().params
+ login = next(v for k, v in params.items() if k.startswith("login"))
+ target = params["auth_version"]
+ updates.append((login, target))
+ if versions.get(login, target) < target:
+ versions[login] = target
+ return None
+ return FakeResult()
+
+ async def scalar(self, _query):
+ raise AssertionError("login is cached, no one-shot lookup expected")
+
+ async def commit(self):
+ return None
+
+ async def rollback(self):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ return updates, lambda: lambda: FakeSession()
+
+
+@pytest.mark.asyncio
+async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
+ """Неудачный logout поднял версию только на этом узле (B). Сверка не
+ откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
+ на A после этого выдаёт cookie, которую B принимает."""
+ login = "failed-logout-user"
+ versions = {login: 4}
+ updates, sessionmaker = _versioned_users_db(versions)
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
+ auth.prime_generations({login: 4})
+ try:
+ auth.invalidate_login(login) # ветка except в logout: БД недоступна
+ assert auth._generations[login] == 5
+
+ await auth.sync_generations()
+ assert updates == [(login, 5)]
+ assert versions[login] == 5, "revocation must reach the shared DB"
+ assert auth._generations[login] == 5, "sync must not roll back a local revocation"
+
+ # Вход на A берёт версию из БД, и теперь она совпадает с версией B.
+ assert versions[login] == auth._generations[login]
+
+ await auth.sync_generations()
+ assert updates == [(login, 5)], "converged nodes must not write again"
+ finally:
+ auth._generations.pop(login, None)
+
+
+@pytest.mark.asyncio
+async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
+ """Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
+ Без записи в БД узел остался бы впереди навсегда."""
+ login = "recreated-user"
+ versions = {}
+ updates, sessionmaker = _versioned_users_db(versions)
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
+ auth.prime_generations({login: 2})
+ try:
+ await auth.sync_generations()
+ assert auth._generations[login] == 3
+ assert login in auth._vanished
+
+ versions[login] = 0 # оператор создал учётку заново
+ await auth.sync_generations()
+ assert updates == [(login, 3)]
+ assert versions[login] == 3
+ assert login not in auth._vanished
+ finally:
+ auth._generations.pop(login, None)
+ auth._vanished.discard(login)
+
+
+def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
+ """Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
+ а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
+ from app.config import get_settings
+
+ user = SimpleNamespace(
+ login="ahead-login", auth_provider="local", password_hash="hash",
+ blocked=False, role="instructor", full_name="Преподаватель",
+ service=None, trainee_id=None, auth_version=1,
+ )
+ updates = []
+
+ class FakeDb:
+ async def scalar(self, _statement):
+ return user
+
+ async def execute(self, query):
+ assert query.is_update
+ updates.append(query.compile().params["auth_version"])
+
+ async def commit(self):
+ return None
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def audit_ok(*_args, **_kwargs):
+ return True
+
+ settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
+ monkeypatch.setattr(auth, "audit", audit_ok)
+ auth._generations[user.login] = 2 # неудачный logout на этом узле
+ try:
+ response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
+ assert response.status_code == 200
+ assert updates == [2]
+ assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
+ assert client.get("/api/auth/me").status_code == 200
+ finally:
+ auth._generations.pop(user.login, None)
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
@@ -662,6 +815,7 @@ def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(m
)
assert results == [9, 9, 9]
assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
+ assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
try:
@@ -734,6 +888,146 @@ def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypa
auth._lookup_locks.pop(login, None)
+def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
+ """При partition SELECT может висеть до таймаута TCP. Разовая проверка
+ ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
+ и для запроса, который ждёт lock за зависшим."""
+ login = "peer-node-hanging-db-login"
+ monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
+
+ class HangingDb:
+ async def scalar(self, _query):
+ await asyncio.Event().wait()
+
+ class FakeSession:
+ async def __aenter__(self):
+ return HangingDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ class InnerApp:
+ called = False
+
+ async def __call__(self, _scope, _receive, _send):
+ self.called = True
+
+ async def run():
+ auth.prime_generations({})
+ cookie_session = {
+ "principal": {"login": login},
+ "auth_instance": auth._INSTANCE,
+ "auth_generation": 0,
+ }
+
+ async def receive():
+ return {"type": "http.request", "body": b"", "more_body": False}
+
+ http_messages, ws_messages = [], []
+
+ async def http_send(message):
+ http_messages.append(message)
+
+ async def ws_send(message):
+ ws_messages.append(message)
+
+ http_app, ws_app = InnerApp(), InnerApp()
+ started = asyncio.get_running_loop().time()
+ await asyncio.wait_for(asyncio.gather(
+ auth.AuthVersionMiddleware(http_app)(
+ {"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
+ receive, http_send,
+ ),
+ auth.AuthVersionMiddleware(ws_app)(
+ {"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
+ receive, ws_send,
+ ),
+ ), timeout=2)
+ elapsed = asyncio.get_running_loop().time() - started
+ assert elapsed < 1, "a queued handshake must not wait for a second timeout"
+ assert not http_app.called and not ws_app.called
+ assert http_messages[0]["status"] == 503
+ assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
+ assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
+
+ settings = auth.get_settings().model_copy(update={"demo_no_db": False})
+ monkeypatch.setattr(auth, "get_settings", lambda: settings)
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ asyncio.run(run())
+
+
+def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
+ """Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
+ вход прошёл через разовую проверку, а не через обычную сверку."""
+ login = "peer-node-logged-login"
+
+ class FakeDb:
+ async def scalar(self, _query):
+ return 0
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ assert await auth._resolve_unknown_login(login) == 0
+
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ with caplog.at_level("WARNING", logger="app.api.auth"):
+ asyncio.run(run())
+ finally:
+ auth._generations.pop(login, None)
+ assert auth.login_log_marker(login) in caplog.text
+ assert login not in caplog.text
+
+
+def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
+ login = "peer-node-replayed-missing-login"
+ lookups = []
+
+ class FakeResult:
+ def all(self):
+ return []
+
+ class FakeDb:
+ async def scalar(self, _query):
+ lookups.append(1)
+ return None
+
+ async def execute(self, _query):
+ return FakeResult()
+
+ class FakeSession:
+ async def __aenter__(self):
+ return FakeDb()
+
+ async def __aexit__(self, *_args):
+ return None
+
+ async def run():
+ auth.prime_generations({})
+ assert await auth._resolve_unknown_login(login) is None
+ assert await auth._resolve_unknown_login(login) is None
+ assert lookups == [1], "a replayed cookie must not query users on every request"
+ assert auth._lookup_locks == {}
+
+ await auth.sync_generations()
+ assert await auth._resolve_unknown_login(login) is None
+ assert lookups == [1, 1], "the negative result lives only until the next sync"
+
+ monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
+ monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
+ try:
+ asyncio.run(run())
+ finally:
+ auth._missing_logins.discard(login)
+
+
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
from app.config import get_settings
From 83709b8b1ef43879f0418402baa8c4806fbbbf67 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:28:49 +0300
Subject: [PATCH 13/41] =?UTF-8?q?fix:=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86?=
=?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0?=
=?UTF-8?q?=D0=B4=20=D0=B1=D1=80=D0=B8=D0=B3=D0=B0=D0=B4=D1=8B=20=E2=80=94?=
=?UTF-8?q?=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D0=B2=20=D1=87?=
=?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20time=5Fmetric,=20=D0=B1=D0=B5=D0=B7?=
=?UTF-8?q?=20=D1=88=D1=82=D1=80=D0=B0=D1=84=D0=B0=20=D0=BF=D1=80=D0=B8=20?=
=?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=D0=B5=20=D0=BE=D1=82=20=D1=80?=
=?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/dispatcher.py | 101 ++++++++++++++--------
backend/app/scoring/export.py | 5 +-
backend/app/scoring/taxonomy.py | 1 +
backend/app/scoring/timing.py | 31 ++++---
frontend/src/features/debrief/Debrief.tsx | 6 +-
5 files changed, 92 insertions(+), 52 deletions(-)
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index ab81dd2..fc921a3 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -11,6 +11,8 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
+import math
+
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
@@ -24,6 +26,9 @@ from app.domain.statuses import (
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.events import Metric
from app.domain.timers import GOST_REF
+from app.scoring.timing import TimeNorm, time_metric
+
+MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
#: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
@@ -43,6 +48,22 @@ REPORT_LABELS = {
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
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 report_reactions(
@@ -70,6 +91,38 @@ def report_reactions(
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(
@@ -79,7 +132,7 @@ def _finding(service: str, code: ErrorCode, summary: str, fact: str,
summary=summary,
fact=fact,
norm=norm,
- ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
+ ref=MEMO_REF,
competency=(
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
),
@@ -328,30 +381,9 @@ def evaluate_dispatcher(
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
- # Код новый не вводится: это тот же неполный ход работ из памятки,
- # только с измеримым временем реакции на доклад.
- terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
- limit_s = report_reaction_ms // 1000
- for report, delay_ms in report_reactions(marks, reports):
- label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
- source = f"доклад бригады «{report.crew}» {REPORT_LABELS[report.phase]}"
- if delay_ms is None and not terminal:
- finding = _finding(
- service, ErrorCode.D6,
- f"{service}: по докладу бригады статус «{label}» не отмечен",
- fact=f"{source} получен, статус «{label}» не отмечен",
- norm=f"отметить статус по докладу не позже {limit_s} с",
- )
- elif delay_ms is not None and delay_ms > report_reaction_ms:
- finding = _finding(
- service, ErrorCode.D6,
- f"{service}: статус «{label}» отмечен позже норматива после доклада",
- fact=f"статус «{label}» отмечен через {round(delay_ms / 1000)} с; {source}",
- norm=f"отметить статус по докладу не позже {limit_s} с",
- )
- else:
- continue
- findings.append(finding.model_copy(update={"metric_key": "dds_report_reaction"}))
+ 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
@@ -393,6 +425,12 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
+ # Метрика есть только там, где бригада докладывала: без докладов
+ # реагировать не на что, и отсутствие звонков не штраф.
+ reports = [report for report in getattr(state, "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"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
@@ -429,17 +467,4 @@ def dispatcher_metrics(
notes_complete,
"; ".join(notes) if notes else "комментарии к статусам не внесены",
"к каждой ручной отметке добавить основание и содержание полученных сведений")
- # Метрика есть только там, где бригада докладывала: без докладов
- # реагировать не на что, и отсутствие звонков не штраф.
- reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
- reactions = report_reactions(marks, reports)
- if reactions:
- add("dds_report_reaction", "реакция на доклады бригады",
- all(delay is not None and delay <= report_reaction_ms for _, delay in reactions),
- "; ".join(
- f"{REPORT_LABELS[report.phase]}: "
- + (f"{round(delay / 1000)} с" if delay is not None else "статус не отмечен")
- for report, delay in reactions
- ),
- f"статус по каждому докладу не позже {report_reaction_ms // 1000} с")
return metrics
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 1eeafc7..2243f4b 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n\ufeff\u200b")
+ probe = result.lstrip(" \t\r\n")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
@@ -98,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("\ufeff" + output.getvalue()).encode("utf-8")
+ return ("" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
@@ -172,6 +172,7 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
+ ("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index be96cd1..43d069c 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -60,4 +60,5 @@ METRIC_WEIGHTS: dict[str, float] = {
"description_grammar": 1.0,
"card_fill_time": 1.5,
"dds_work_time": 1.5,
+ "dds_report_reaction": 1.5,
}
diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py
index 5b9deb1..a2cd944 100644
--- a/backend/app/scoring/timing.py
+++ b/backend/app/scoring/timing.py
@@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
@dataclass(frozen=True)
class TimeNorm:
- """Формулировки норматива времени: карточка 112 и карточка ДДС различаются
- только словами, расчёт и отметка E3 у них общие."""
+ """Формулировки норматива времени: нормативы различаются словами и кодом
+ отметки, расчёт вклада и формат факта у них общие."""
key: str
title: str
@@ -32,6 +32,10 @@ class TimeNorm:
action: str
unmeasured: str
incomplete: str
+ code: ErrorCode = ErrorCode.E3
+ source: FindingSource = FindingSource.TIMERS
+ competency: Competency = Competency.NORMS
+ ref: str = TIME_REF
CARD_FILL_TIME = TimeNorm(
@@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
def time_metric(
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
) -> tuple[Metric, Finding | None]:
- """Метрика норматива и отметка E3, если он не выполнен.
+ """Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
Незавершённая работа не получает временного вклада: быстрое, но брошенное
заполнение не должно выглядеть скоростью.
@@ -64,13 +68,20 @@ def time_metric(
fact = norm.unmeasured
else:
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:
- deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
+ deviation = f"+{seconds(delta_ms)} с сверх норматива"
elif delta_ms < 0:
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
else:
deviation = "точно в норматив"
- fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
+ fact = f"{seconds(elapsed_ms)} с ({deviation})"
if not completed:
fact = f"{norm.incomplete}; {fact}"
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
@@ -80,7 +91,7 @@ def time_metric(
title=norm.title,
fact=fact,
norm=norm_text,
- ref=TIME_REF,
+ ref=norm.ref,
passed=passed,
weight=METRIC_WEIGHTS[norm.key],
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
@@ -88,12 +99,12 @@ def time_metric(
if passed:
return metric, None
return metric, Finding(
- code=ErrorCode.E3,
- source=FindingSource.TIMERS,
+ code=norm.code,
+ source=norm.source,
summary=norm.summary,
fact=fact,
norm=norm_text,
- ref=TIME_REF,
- competency=Competency.NORMS,
+ ref=norm.ref,
+ competency=norm.competency,
metric_key=norm.key,
)
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 0b3230e..fae3420 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -75,10 +75,12 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
- {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады —
- {` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}>}
+ {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${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.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
>}.
From 584f86f796e5d4c8ed8a8effff8dac8bf5540922 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:29:57 +0300
Subject: [PATCH 14/41] =?UTF-8?q?test:=20=D1=81=D0=BC=D0=BE=D1=83=D0=BA=20?=
=?UTF-8?q?=D0=B4=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?=
=?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D1=83=D1=8E=20=D0=BF=D1=80=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D1=83=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BD?=
=?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20B=20=D0=BF=D0=BE=20=D0=B6=D1=83=D1=80?=
=?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=83,=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20?=
=?UTF-8?q?=D0=BE=D1=82=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F?=
=?UTF-8?q?=20=D1=83=D1=87=D1=91=D1=82=D0=BE=D0=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/test_cluster_failover.py | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/scripts/test_cluster_failover.py b/scripts/test_cluster_failover.py
index a907266..6864f92 100644
--- a/scripts/test_cluster_failover.py
+++ b/scripts/test_cluster_failover.py
@@ -209,7 +209,7 @@ async def main(args: argparse.Namespace) -> int:
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
- from app.api.auth import hash_password
+ from app.api.auth import hash_password, login_log_marker
from app.db.models import AuditLog, Session, Trainee, User
database_url = (
@@ -250,19 +250,26 @@ async def main(args: argparse.Namespace) -> int:
trainee_id=trainee.id, blocked=False),
])
await db.commit()
+ users_committed_at = time.monotonic()
instructor_cookie = login(backend, instructor_login, instructor_password)
- login_completed_at = time.monotonic()
trainee_cookie = login(backend, trainee_login, trainee_password)
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
"mode": "training", "exercise": "dds",
})
- result["login_to_control_seconds"] = round(control_sent_at - login_completed_at, 4)
+ result["users_commit_to_control_seconds"] = round(control_sent_at - users_committed_at, 4)
if args.expect_initial_owner == "backend-b":
result["checks"]["control_sent_within_first_second"] = (
- result["login_to_control_seconds"] < 1
+ result["users_commit_to_control_seconds"] < 1
+ )
+ # Сверка B идёт раз в секунду в случайной фазе, и время само по себе
+ # не доказывает, что B не знал логин. Доказательство — запись B о
+ # разовой проверке именно этого логина.
+ result["checks"]["backend_b_resolved_login_one_shot"] = (
+ login_log_marker(instructor_login)
+ in compose(args, "logs", "--no-color", "backend-b")
)
async def session_row():
From d403965f4936a4f97b438b7c008ec96835b89f19 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:30:18 +0300
Subject: [PATCH 15/41] =?UTF-8?q?fix:=20=D0=B4=D0=B8=D1=80=D0=B5=D0=BA?=
=?UTF-8?q?=D1=82=D0=B8=D0=B2=D1=8B=20=D0=BD=D0=B5=D0=B0=D0=BA=D1=82=D0=B8?=
=?UTF-8?q?=D0=B2=D0=BD=D1=8B=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D1=83=D0=B7?=
=?UTF-8?q?=D0=B5,=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=BD?=
=?UTF-8?q?=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BD?=
=?UTF-8?q?=D0=B5=20=D1=83=D1=85=D0=BE=D0=B4=D1=8F=D1=82=20=D0=B2=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B5=20=D0=B7=D0=B0=D0=BD?=
=?UTF-8?q?=D1=8F=D1=82=D0=B8=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/features/call/useCall.ts | 7 +++++++
frontend/src/pages/instructor/Instructor.tsx | 2 +-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts
index c61caae..be4a2e9 100644
--- a/frontend/src/features/call/useCall.ts
+++ b/frontend/src/features/call/useCall.ts
@@ -60,6 +60,7 @@ export function useCall(sessionId: string | null) {
const patchTimer = useRef | null>(null);
const textMode = useRef(false);
const pausedRef = useRef(false);
+ const endedRef = useRef(false);
const append = useCallback((line: Line) => {
setLines((prev) => {
@@ -70,6 +71,7 @@ export function useCall(sessionId: string | null) {
useEffect(() => {
if (!sessionId) return;
+ endedRef.current = false;
const ch = callChannel(sessionId, {
onStatus: (next) => {
setStatus(next);
@@ -133,6 +135,7 @@ export function useCall(sessionId: string | null) {
setTimers(event.timers);
break;
case "call.ended":
+ endedRef.current = true;
setPhase("ended");
audio.current?.ambience.stop();
if (textMode.current) { setCardSubmitted(true); break; }
@@ -154,6 +157,10 @@ export function useCall(sessionId: string | null) {
setCallerSpeaking(false);
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = null;
+ } else if (endedRef.current) {
+ // Занятие завершили прямо на паузе: сервер снимает её после
+ // `call.ended`, и отложенные правки уже не войдут в оценку.
+ outbox.current = {};
} else {
// При переподключении сервер первым присылает состояние паузы.
// Только после этого повторяем правки, ожидающие подтверждения.
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index 0dc44c0..52e0f6f 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -708,7 +708,7 @@ export function Instructor() {
)}
{sessionId && session.title && !session.ended && (
- exercise === "call" &&
+ exercise === "call" &&
)}
{links && startPendingId && (
Создаём занятие. Ссылки появятся автоматически после подключения.
From 59fcbb3d6ba8685e12197b48daceb4d53a3bd8d9 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:32:12 +0300
Subject: [PATCH 16/41] =?UTF-8?q?test:=20=D1=8E=D0=BD=D0=B8=D1=82-=D1=82?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8?=
=?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4=20?=
=?UTF-8?q?=D0=BF=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D1=83=20?=
=?UTF-8?q?=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8=D0=B2=D0=BE=D0=B2=20?=
=?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8,=20=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BC=D0=B8=D1=82=20=D0=B2=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?=
=?UTF-8?q?=D1=80=D1=82=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_dispatcher_scoring.py | 78 +++++++++++++++++++++---
backend/tests/test_report_export.py | 2 +
2 files changed, 73 insertions(+), 7 deletions(-)
diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py
index f6d562c..f4ba2df 100644
--- a/backend/tests/test_dispatcher_scoring.py
+++ b/backend/tests/test_dispatcher_scoring.py
@@ -5,9 +5,10 @@ import pytest
from pydantic import ValidationError
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.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.timers import SessionTimers
@@ -340,17 +341,39 @@ 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.fact == "о прибытии: 10 с"
+ 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 "«Прибытие» отмечен через 70 с" in findings[0].fact
+ assert "«Прибытие»" in findings[0].fact and "70 с (+25 с сверх норматива)" in findings[0].fact
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
- assert findings[0].norm == "отметить статус по докладу не позже 45 с"
- assert not metric.passed and metric.norm == "статус по каждому докладу не позже 45 с"
+ 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():
@@ -360,15 +383,28 @@ def test_missing_status_after_crew_report_is_d6():
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.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 "не позже 20 с" in strict[0].norm
+ assert strict[0].norm == "отметить статус по докладу за 20 с"
def test_no_crew_reports_means_no_reaction_metric():
@@ -377,6 +413,34 @@ def test_no_crew_reports_means_no_reaction_metric():
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(
diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py
index 9a87484..0ee91f4 100644
--- a/backend/tests/test_report_export.py
+++ b/backend/tests/test_report_export.py
@@ -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] == "'+HYPERLINK(\"x\")" 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("\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
def test_certificate_pdf_contains_saved_result(tmp_path):
From db8787c6b39c9546269d3c657be407fc89cc4b54 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:32:41 +0300
Subject: [PATCH 17/41] =?UTF-8?q?fix:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?=
=?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9=20?=
=?UTF-8?q?=D0=BF=D0=BE=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B5,=20?=
=?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=87=D1=91=D1=82=20=D0=BA=D0=BE?=
=?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=B8=20=D0=B1=D0=B0=D0=BB=D0=BB=D0=BE?=
=?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA,=20?=
=?UTF-8?q?=D0=B2=D0=B5=D1=81=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?=
=?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=B8=D0=B7=20=D0=B2=D0=B5=D1=81=D0=BE?=
=?UTF-8?q?=D0=B2=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/domain/taxonomy.py | 26 ++++++++++++--
backend/app/scoring/review.py | 62 ++++++++++++++++++++++++++++-----
backend/app/scoring/taxonomy.py | 5 +++
backend/app/session/pg_store.py | 23 ++++++++----
4 files changed, 100 insertions(+), 16 deletions(-)
diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py
index b1365b2..476212f 100644
--- a/backend/app/domain/taxonomy.py
+++ b/backend/app/domain/taxonomy.py
@@ -7,8 +7,10 @@
from datetime import datetime
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):
@@ -188,4 +190,24 @@ class Finding(BaseModel):
service: str | None = None
#: Автор ручной отметки преподавателя (`source=instructor`).
author: str | None = None
- review: FindingReview | 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
diff --git a/backend/app/scoring/review.py b/backend/app/scoring/review.py
index 1a2691f..67425a2 100644
--- a/backend/app/scoring/review.py
+++ b/backend/app/scoring/review.py
@@ -14,12 +14,10 @@ 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 METRIC_MAP
+from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
-#: Метрика для ручной отметки, которой нечего провалить: у кода нет метрики
-#: в METRIC_MAP (D4) или в карточке она не считалась. Вес — как у метрики по
-#: умолчанию, чтобы отметка преподавателя не весила больше автоматической.
-INSTRUCTOR_METRIC = "instructor_finding"
+__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
+ "reviewed_metrics"]
def _in_scope(finding: Finding, metric: Metric) -> bool:
@@ -46,7 +44,8 @@ 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]) -> list[Metric]:
+def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
+ instructor_weight: float = 1.0) -> list[Metric]:
"""Метрики с учётом решений преподавателя.
Проваленная метрика засчитывается, только если все её автоматические
@@ -93,15 +92,47 @@ def reviewed_metrics(metrics: list[Metric], findings: list[Finding]) -> list[Met
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 = {
@@ -114,12 +145,17 @@ def _rescore(report: dict, findings: list[Finding]) -> dict:
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")),
@@ -132,16 +168,26 @@ def _findings(report: dict) -> list[Finding]:
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={"review": review})
+ 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])
diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py
index 28f8dc8..28f094f 100644
--- a/backend/app/scoring/taxonomy.py
+++ b/backend/app/scoring/taxonomy.py
@@ -35,6 +35,11 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
}
+#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
+#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
+#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
+INSTRUCTOR_METRIC = "instructor_finding"
+
#: Вес метрики в детерминированной оценке.
#:
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
diff --git a/backend/app/session/pg_store.py b/backend/app/session/pg_store.py
index 4416c54..e77f351 100644
--- a/backend/app/session/pg_store.py
+++ b/backend/app/session/pg_store.py
@@ -47,6 +47,7 @@ from app.session.store import (
apply_finding_change,
apply_score_override,
)
+from app.scoring.review import has_finding
log = logging.getLogger(__name__)
LEASE_SECONDS = 15
@@ -249,18 +250,28 @@ class PostgresSessionStore:
)
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} {record.review.decision.value}; "
- f"reason_chars={len(record.review.reason)}")
+ what = (f"#{record.index} {code} {record.review.decision.value}; "
+ f"причина: {record.review.reason}")
else:
- actor, action = record.finding.author or "instructor", "finding.add"
- what = f"{record.finding.code.value}; fact_chars={len(record.finding.fact)}"
- # Как у правки итога: текст решения живёт в разборе, в аудите —
- # что изменено, кем и как сдвинулся балл.
+ 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),
From 4747b0a11858a5fc4a54d94d638fb9bd844740db Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:33:42 +0300
Subject: [PATCH 18/41] =?UTF-8?q?fix:=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE?=
=?UTF-8?q?=D1=80=D1=82=20CSV=20=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=B4?=
=?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B8=D1=82=20BOM=20=D1=8D=D0=BA=D1=80=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20?=
=?UTF-8?q?=D0=B2=20=D0=B8=D1=81=D1=85=D0=BE=D0=B4=D0=BD=D0=B8=D0=BA=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/export.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 2243f4b..0b664e7 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n")
+ probe = result.lstrip(" \t\r\n\\ufeff\\u200b")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
@@ -98,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("" + output.getvalue()).encode("utf-8")
+ return ("\\ufeff" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
From 21f695344b84c784f26814fe7306735c51103c12 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:35:16 +0300
Subject: [PATCH 19/41] =?UTF-8?q?fix:=20BOM=20=D0=B2=20=D1=8D=D0=BA=D1=81?=
=?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B5=20CSV=20=D1=87=D0=B5=D1=80=D0=B5?=
=?UTF-8?q?=D0=B7=20=D0=B8=D0=BC=D1=8F=20=D1=81=D0=B8=D0=BC=D0=B2=D0=BE?=
=?UTF-8?q?=D0=BB=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20escape-?=
=?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/export.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 0b664e7..ec7b767 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
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")):
return "'" + result
return result
@@ -98,7 +98,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
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]:
From da740dce7fcfc00f4dd45c804b4d6481a6e97ada Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:35:28 +0300
Subject: [PATCH 20/41] =?UTF-8?q?fix:=20E-=D0=BA=D0=BE=D0=B4=D1=8B=20?=
=?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?=
=?UTF-8?q?=D0=B8=20=D0=B2=20=D1=81=D0=B2=D1=8F=D0=B7=D0=BA=D0=B5=20112=20?=
=?UTF-8?q?=E2=86=92=20=D0=94=D0=94=D0=A1,=20=D0=B8=D0=B4=D0=B5=D0=BC?=
=?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=B0=D1=8F=20=D1=80?=
=?UTF-8?q?=D1=83=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82?=
=?UTF-8?q?=D0=BA=D0=B0,=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20=D0=BF=D0=BE?=
=?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B5=20=D0=BF=D0=BE=D1=81?=
=?UTF-8?q?=D0=BB=D0=B5=20commit?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 36 ++++++++++++++++++++++++++------
1 file changed, 30 insertions(+), 6 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index a1122b3..6379d08 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -46,9 +46,9 @@ from app.session.store import (
FindingAdded,
FindingReviewed,
ScoreOverridden,
- apply_finding_change,
apply_score_override,
)
+from app.scoring.review import has_finding
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -624,6 +624,8 @@ class FindingIn(BaseModel):
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)
@@ -648,7 +650,8 @@ async def _change_findings(
raise HTTPException(status_code=409, detail="scenario_not_found")
change = make(who, state.score)
async with hub.operation(session_id):
- change_findings(state, change)
+ if not _repeated(state.score, change):
+ change_findings(state, change)
return build_report(session_id, state, scenario)
if db is None:
@@ -662,11 +665,24 @@ async def _change_findings(
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")
- change = make(who, dict(score.report))
- report = apply_finding_change(dict(score.report), change)
+ current = dict(score.report)
+ change = make(who, current)
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
- await hub.store.commit_archived(session_id, [change])
- return SessionReport.model_validate(report["full_report"])
+ 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)
@@ -703,6 +719,13 @@ async def add_finding(
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):
@@ -722,6 +745,7 @@ async def add_finding(
card=body.card,
service=service,
author=who.login,
+ client_id=body.client_id,
))
return await _change_findings(session_id, who, db, make)
From 7eb4d1724883c083f4b135dfcdb147209f51269c Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:37:19 +0300
Subject: [PATCH 21/41] =?UTF-8?q?fix:=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?=
=?UTF-8?q?=D0=B0=20=D0=BF=D0=B0=D1=83=D0=B7=20=D0=B2=20=D1=80=D0=B0=D0=B7?=
=?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=D1=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/features/debrief/Debrief.tsx | 5 +++--
frontend/src/pages/dds/Dds.tsx | 5 +++--
2 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index f23dd8a..5e639b1 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -20,7 +20,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title]));
const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : [];
- const pausedSeconds = Math.round((report.total_paused_ms ?? 0) / 1000);
+ // Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
+ const pausedSeconds = Math.ceil((report.total_paused_ms ?? 0) / 1000);
return (
@@ -44,7 +45,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.attempt ? `Попытка ${report.attempt}` : "Завершённое упражнение"}
- Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
+ {pausedSeconds > 0 && Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
}
Служебные сведения
Идентификатор сценария: {report.scenario_id} · номер занятия: {report.session_id}
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 068bd7a..8734a5f 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -331,7 +331,8 @@ export function Dds() {
const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
const isConnected = status === "open";
- const pausedSeconds = Math.round((report?.total_paused_ms ?? 0) / 1000);
+ // Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
+ const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
const cardId = card ? String(readPath(card.server, "card_id") ?? "") : "";
const cardIndex = station?.card_index ?? 1;
const cardTotal = station?.card_total ?? 1;
@@ -379,7 +380,7 @@ export function Dds() {
{report ?
Итоговая оценка: {report.score_final.toFixed(0)} из 100
{report.score_final !== report.score_auto && ` · автооценка ${report.score_auto.toFixed(0)}`}
-
Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
+ {pausedSeconds > 0 &&
Паузы занятия: {Math.floor(pausedSeconds / 60)} мин {pausedSeconds % 60} с. Время пауз исключено из нормативов.
}
Скачать отчёт: CSV
{" · "}PDF
{Boolean(report.card_results?.length) && <>
From 7b9987e78f1b10d46570ac26cb0ae07b2209d2ba Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:40:20 +0300
Subject: [PATCH 22/41] =?UTF-8?q?test:=20=D1=81=D0=BA=D0=B2=D0=BE=D0=B7?=
=?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8=D1=8F?=
=?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4=20=D1=87?=
=?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8?=
=?UTF-8?q?=D1=8E,=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BE=D1=82=20?=
=?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=20=D0=B8=20=D0=B2=D0=BE=D1=81?=
=?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D0=B5=20=D0=B8=D0=B7=20checkpoint?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_dds_exercise.py | 114 ++++++++++++++++++++++-
backend/tests/test_session_checkpoint.py | 35 ++++++-
2 files changed, 146 insertions(+), 3 deletions(-)
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index b027fad..70a4e49 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -2,7 +2,7 @@
import asyncio
import time
-from datetime import datetime
+from datetime import datetime, timedelta
from uuid import uuid4
import pytest
@@ -800,7 +800,117 @@ 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 not score["findings"]
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
- assert reaction and all(m["passed"] for m in 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:
control.__exit__(None, None, None)
diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py
index d5842e8..e572a22 100644
--- a/backend/tests/test_session_checkpoint.py
+++ b/backend/tests/test_session_checkpoint.py
@@ -12,12 +12,13 @@ from app.domain.events import (
SessionMode,
)
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.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state
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
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
+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:
return DdsCardRecord(
card_id=uuid4(),
From 871832d5ca1523ddd011d7bb6edea625a5e8353a Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:41:27 +0300
Subject: [PATCH 23/41] =?UTF-8?q?fix:=20=D0=B1=D0=B0=D0=BB=D0=BB=20=D0=BF?=
=?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80?=
=?UTF-8?q?=D0=B0=20=D0=B2=20PDF,=20=D0=B2=D1=81=D0=B5=20=D1=80=D0=B5?=
=?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D0=BE=D1=82?=
=?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B5=20=D0=B2=20=D1=8D=D0=BA=D1=81?=
=?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B5,=20=D0=B2=D0=B5=D1=81=20=D0=BE?=
=?UTF-8?q?=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B5=D0=BF?=
=?UTF-8?q?=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2?=
=?UTF-8?q?=20=D0=BA=D1=80=D0=B8=D1=82=D0=B5=D1=80=D0=B8=D1=8F=D1=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/domain/events.py | 6 ++++--
backend/app/scoring/export.py | 19 +++++++++++--------
2 files changed, 15 insertions(+), 10 deletions(-)
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 3cd3790..0d5f931 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -23,7 +23,7 @@ from app.domain.roles import Role
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
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):
@@ -60,7 +60,7 @@ class LessonCriteria(BaseModel):
@field_validator("score_weights")
@classmethod
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:
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
if any(not 0 <= weight <= 10 for weight in weights.values()):
@@ -747,6 +747,8 @@ class DdsCardReport(BaseModel):
card_id: UUID
scenario_id: str
score_auto: float
+ #: Балл карточки после решений преподавателя по отметкам; None — решений не было.
+ score_reviewed: float | None = None
reply_text: str
metrics: list[Metric]
findings: list[Finding]
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 9ee879b..2ea4cf6 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n\ufeff\u200b")
+ probe = result.lstrip(" \t\r\n")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
@@ -32,12 +32,12 @@ def _finding_origin(item: Finding) -> str:
def _finding_decision(item: Finding) -> str:
- """Решение по отметке рядом с ней: снятая отметка видна вместе с причиной."""
- if item.review is None:
- return ""
- verdict = ("снята" if item.review.decision is FindingDecision.DISMISSED
- else "подтверждена")
- return f"; {verdict} преподавателем {item.review.author}: {item.review.reason}"
+ """Решения по отметке рядом с ней, все по порядку: действует последнее."""
+ 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:
@@ -96,7 +96,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("\ufeff" + output.getvalue()).encode("utf-8")
+ return ("" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
@@ -179,6 +179,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Итоговая оценка", report.score_final),
):
story.append(Paragraph(f"
{escape(label)}: {escape(str(value))}", base))
+ # Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
+ if report.score_reviewed is not None:
+ story.append(Paragraph(f"
После разбора отметок: {report.score_reviewed}", base))
if report.overridden_by:
story.append(Paragraph(f"
Оценка изменена: {escape(report.overridden_by)}", base))
if report.override_comment:
From eefb5e75fe53ac5ef79227fa8e99e1708a1fc1b6 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:44:03 +0300
Subject: [PATCH 24/41] =?UTF-8?q?test:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80?=
=?UTF-8?q?=D0=B8=D1=8F=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9,=20?=
=?UTF-8?q?=D1=81=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20112=20=E2=86=92=20=D0=94?=
=?UTF-8?q?=D0=94=D0=A1,=20=D0=BE=D1=82=D0=BA=D0=B0=D0=B7=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=B8=20=D1=81=D0=B1=D0=BE=D0=B5=20=D0=B0=D1=83=D0=B4=D0=B8?=
=?UTF-8?q?=D1=82=D0=B0,=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=B7?=
=?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D1=8F=20=D0=B8=D0=B7=20=D0=B8?=
=?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_finding_review.py | 349 ++++++++++++++++++++++++++-
1 file changed, 343 insertions(+), 6 deletions(-)
diff --git a/backend/tests/test_finding_review.py b/backend/tests/test_finding_review.py
index 227d74d..c8477f6 100644
--- a/backend/tests/test_finding_review.py
+++ b/backend/tests/test_finding_review.py
@@ -73,8 +73,8 @@ def test_dismissed_finding_stops_penalizing_its_metric_but_stays_in_report():
progress = updated["metrics"][1]
assert (progress["passed"], progress["passed_auto"]) == (True, False)
finding = updated["findings"][0]
- assert finding["review"]["decision"] == "dismissed"
- assert finding["review"]["reason"] == "доклад пришёл по рации"
+ 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)
@@ -204,8 +204,8 @@ def test_owner_dismisses_and_adds_finding_through_http(lesson):
)
assert dismissed.status_code == 200, dismissed.text
body = dismissed.json()
- assert body["findings"][target]["review"]["decision"] == "dismissed"
- assert body["findings"][target]["review"]["author"] == "dev"
+ 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"]
@@ -221,7 +221,7 @@ def test_owner_dismisses_and_adds_finding_through_http(lesson):
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]["review"]["decision"] == "dismissed"
+ 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
@@ -302,6 +302,343 @@ def test_finding_review_is_audited_in_postgres(postgres_access):
score, audit = asyncio.run(scenario())
assert score.score_auto == 50.0
assert score.score_final == 100.0
- assert score.report["findings"][0]["review"]["decision"] == "dismissed"
+ 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 "
После разбора отметок: 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
From 0d54fcc893ac2ca3e96298e4bf6b1b930e1f5901 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:48:50 +0300
Subject: [PATCH 25/41] =?UTF-8?q?fix:=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20?=
=?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20=D1=81=20=D0=B8?=
=?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B5=D0=B9=20=D1=80=D0=B5=D1=88?=
=?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?=
=?UTF-8?q?=D0=BE=D0=BC=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?=
=?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20?=
=?UTF-8?q?D-=D0=BA=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=BB=D1=8E?=
=?UTF-8?q?=D1=87=D0=BE=D0=BC=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/features/debrief/FindingReview.tsx | 38 +++++++++++++------
frontend/src/shared/types/generated.ts | 4 +-
2 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/frontend/src/features/debrief/FindingReview.tsx b/frontend/src/features/debrief/FindingReview.tsx
index 7465dbb..ef47f23 100644
--- a/frontend/src/features/debrief/FindingReview.tsx
+++ b/frontend/src/features/debrief/FindingReview.tsx
@@ -3,7 +3,7 @@
// Балл пересчитывает сервер теми же метриками и весами (lct-35): здесь только
// решение и его причина. Причина обязательна — она остаётся в отчёте и в CSV/PDF.
-import { useState } from "react";
+import { useRef, useState } from "react";
import type { ErrorCode, FindingDecision, SessionReport } from "@/shared/types/generated";
@@ -34,7 +34,13 @@ export function FindingReview({ report, onChange }: {
: cards.length ? [...CALL_CODES, ...DDS_CODES] : CALL_CODES;
const [reasons, setReasons] = useState
>({});
const [code, setCode] = useState(codes[0]);
- const [card, setCard] = useState(cards.length ? "1" : "");
+ // В ДДС все метрики принадлежат карточкам очереди. В связке 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(null);
const [fact, setFact] = useState("");
const [norm, setNorm] = useState("");
const [busy, setBusy] = useState(false);
@@ -71,10 +77,14 @@ export function FindingReview({ report, onChange }: {
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: card ? Number(card) : null,
+ code, fact: fact.trim(), norm: norm.trim(), card: cardChoice && card ? Number(card) : null,
+ client_id: clientId,
});
+ pendingId.current = null;
setFact("");
setNorm("");
return updated;
@@ -97,9 +107,11 @@ export function FindingReview({ report, onChange }: {
{finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
- {finding.review &&
- {finding.review.decision === "dismissed" ? "Снята" : "Подтверждена"}: {finding.review.reason}
-
}
+ {(finding.reviews ?? []).map((review, reviewIndex, all) =>
+ {review.decision === "dismissed" ? "Снята" : "Подтверждена"} ({review.author},{" "}
+ {new Date(review.at).toLocaleString("ru-RU")}): {review.reason}
+
)}
Добавить отметку
Код
- setCode(event.target.value as ErrorCode)}>
+ {
+ pendingId.current = null;
+ setCode(event.target.value as ErrorCode);
+ }}>
{codes.map((item) => {item} )}
- {cards.length > 0 &&
Карточка
- setCard(event.target.value)}>
+ {cardChoice && Карточка
+ { pendingId.current = null; setCard(event.target.value); }}>
+ {report.exercise !== "dds" && занятие (без карточки) }
{cards.map((item, index) =>
{index + 1}: {item.title || item.scenario_id}
)}
}
Факт
- setFact(event.target.value)}
+ { pendingId.current = null; setFact(event.target.value); }}
placeholder="Что сделал курсант" />
Норма
- setNorm(event.target.value)}
+ { pendingId.current = null; setNorm(event.target.value); }}
placeholder="Как требовалось" />
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 18fbdac..0f1220a 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -177,6 +177,7 @@ export interface DdsCardReport {
card_id: string;
scenario_id: string;
score_auto: number;
+ score_reviewed?: number | null;
reply_text: string;
metrics: Array;
findings: Array;
@@ -271,7 +272,8 @@ export interface Finding {
card?: number | null;
service?: string | null;
author?: string | null;
- review?: FindingReview | null;
+ client_id?: string | null;
+ reviews?: Array;
}
/** Решение преподавателя по отметке на разборе. */
From 61662c9d0910446cdb5cd6a91ceb5d82826f32af Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:51:37 +0300
Subject: [PATCH 26/41] =?UTF-8?q?fix:=20=D0=B2=D0=B5=D1=80=D0=BD=D1=83?=
=?UTF-8?q?=D1=82=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B8=D1=80=D0=BE?=
=?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20BOM=20=D0=B8=20=D0=BD?=
=?UTF-8?q?=D1=83=D0=BB=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B1?=
=?UTF-8?q?=D0=B5=D0=BB=20=D0=B2=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80?=
=?UTF-8?q?=D1=82=D0=B5=20CSV?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/export.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 2ea4cf6..0165040 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n")
+ probe = result.lstrip(" \t\r\n\\ufeff\\u200b")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
@@ -96,7 +96,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("" + output.getvalue()).encode("utf-8")
+ return ("\\ufeff" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
From 2b4516a6a8c3d5bbcfc9bc81fcf067dd7e2d0eec Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:54:00 +0300
Subject: [PATCH 27/41] =?UTF-8?q?fix:=20=D0=BE=D0=B4=D0=B8=D0=BD=D0=B0?=
=?UTF-8?q?=D1=80=D0=BD=D0=BE=D0=B5=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B8?=
=?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20BOM=20=D0=B8=20?=
=?UTF-8?q?=D0=BD=D1=83=D0=BB=D0=B5=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80?=
=?UTF-8?q?=D0=BE=D0=B1=D0=B5=D0=BB=D0=B0=20=D0=B2=20=D1=8D=D0=BA=D1=81?=
=?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D0=B5=20CSV?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/export.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 0165040..2ea4cf6 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n\\ufeff\\u200b")
+ probe = result.lstrip(" \t\r\n")
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
return "'" + result
return result
@@ -96,7 +96,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("\\ufeff" + output.getvalue()).encode("utf-8")
+ return ("" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
From b61036b6fcf9efe7c7459c9b743dc89142b63359 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 22:59:01 +0300
Subject: [PATCH 28/41] =?UTF-8?q?fix:=20BOM=20=D0=B8=20=D0=BD=D1=83=D0=BB?=
=?UTF-8?q?=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB?=
=?UTF-8?q?=20=D0=B2=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80=D1=82=D0=B5=20?=
=?UTF-8?q?CSV=20=D0=BF=D0=BE=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D1=81?=
=?UTF-8?q?=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=B0=20=D0=B2=D0=BC=D0=B5=D1=81?=
=?UTF-8?q?=D1=82=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/scoring/export.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 2ea4cf6..2070e61 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -19,7 +19,7 @@ def _cell(value: object) -> str:
if value is None:
return ""
result = str(value)
- probe = result.lstrip(" \t\r\n")
+ 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")):
return "'" + result
return result
@@ -96,7 +96,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
- return ("" + output.getvalue()).encode("utf-8")
+ return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
def _font_paths() -> tuple[Path, Path | None]:
From a848f89da0e2c5e182696209eda3fa92f59c239f Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:04:54 +0300
Subject: [PATCH 29/41] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE?=
=?UTF-8?q?=D1=80=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5?=
=?UTF-8?q?=D1=82=20=D0=B2=D1=81=D0=B5=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD?=
=?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D0=B1=D0=B0=D0=BB=D0=BB=20=D0=BA=D0=B0?=
=?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB?=
=?UTF-8?q?=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0,=20=D0=B2?=
=?UTF-8?q?=D0=B5=D1=81=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20?=
=?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=BF=D0=BE=D0=B4=D0=B0=D0=B2=D0=B0=D1=82?=
=?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?=
=?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/features/debrief/Debrief.tsx | 9 +++++----
frontend/src/features/instructor/ScoreWeightsEditor.tsx | 5 +++++
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 8d69d3b..59b28df 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -90,7 +90,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{Boolean(report.card_results?.length) &&
Карточки занятия ({report.card_results!.length})
{report.card_results!.map((card, index) =>
- Карточка {index + 1}: {card.title || card.scenario_id} · {card.score_auto.toFixed(0)} / 100
+ Карточка {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}` : ""}
{card.managed_service && Ответственная служба: {card.managed_service}
}
Ответ диспетчера: {card.reply_text || "не внесён"}
@@ -146,9 +147,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{finding.summary}
{finding.fact}{finding.norm && ` · ${finding.norm}`}
{finding.source === "instructor" && отметка преподавателя {finding.author ?? ""}
}
- {finding.review &&
- {finding.review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем: {finding.review.reason}
-
}
+ {(finding.reviews ?? []).map((review, reviewIndex) =>
+ {review.decision === "dismissed" ? "снята" : "подтверждена"} преподавателем {review.author}: {review.reason}
+
)}
))}
diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
index 9f81065..8134184 100644
--- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx
+++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
@@ -39,6 +39,10 @@ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
"required_fields", "description_grammar", "card_fill_time",
].includes(key));
const CALL_METRICS = OPERATOR_METRICS.filter(({ key }) => key !== "card_fill_time");
+// Метрика появляется, только когда ручной отметке преподавателя нечего провалить.
+const REVIEW_METRICS: MetricOption[] = [
+ { key: "instructor_finding", label: "Отметка преподавателя", hint: "Ручная отметка на разборе без связанной метрики (например, D4)." },
+];
export function ScoreWeightsEditor({
exercise,
@@ -57,6 +61,7 @@ export function ScoreWeightsEditor({
: exercise === "card_dds"
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
+ groups.push(["Разбор", REVIEW_METRICS]);
function setWeight(key: string, raw: string) {
const next = { ...values };
From 7abbce0ef88b15da49f73b3fd3c3c03ec0a8745a Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:08:12 +0300
Subject: [PATCH 30/41] =?UTF-8?q?fix:=20=D0=B1=D0=B0=D0=BB=D0=BB=20=D0=BA?=
=?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81?=
=?UTF-8?q?=D0=BB=D0=B5=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=D0=B0=20?=
=?UTF-8?q?=D0=B2=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B5=20=D1=83=D0=BF=D1=80?=
=?UTF-8?q?=D0=B0=D0=B6=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=94=D0=94=D0=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/pages/dds/Dds.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 90d23df..438f3c2 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -379,7 +379,8 @@ export function Dds() {
{Boolean(report.card_results?.length) && <>
Результаты по карточкам
{report.card_results?.map((item, index) =>
- Карточка {index + 1} · {item.scenario_id} · {item.score_auto.toFixed(0)} / 100
+ Карточка {index + 1} · {item.scenario_id} · {(item.score_reviewed ?? item.score_auto).toFixed(0)} / 100
+ {item.score_reviewed != null && ` после разбора (автооценка ${item.score_auto.toFixed(0)})`}
Ответ диспетчера: {item.reply_text || "не внесён"}
{item.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
From 73c7ca6db23feda11429ead9bbdb5226c6185c71 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:14:35 +0300
Subject: [PATCH 31/41] =?UTF-8?q?chore:=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B5=20=D1=81=20main,=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?=
=?UTF-8?q?=D1=88=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=B2=20=D1=80=D0=B5=D0=B5=D1=81=D1=82?=
=?UTF-8?q?=D1=80=D0=B5=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=B8=D0=B9,=20?=
=?UTF-8?q?=D1=81=D0=BE=D0=BA=D0=B5=D1=82=D0=B5=20call=20=D0=B8=20=D1=82?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B5=20checkpoint?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 72 ++++++++++++++++++++++++--
backend/app/api/ws/call.py | 48 +++++++++++++----
backend/tests/test_checkpoint_model.py | 3 ++
3 files changed, 111 insertions(+), 12 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index e6d3569..1996e95 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -8,6 +8,7 @@ import logging
import time
from collections.abc import AsyncIterator
from datetime import UTC, datetime
+from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -23,8 +24,8 @@ from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role
-from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
-from app.domain.timers import TimerCode
+from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
+from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.session.access import can_access
@@ -32,6 +33,7 @@ from app.session.checkpoint import load_state
from app.session.finish import override_score
from app.session.score import scoring_scenario
from app.session.hub import hub
+from app.session.state import SessionState
from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path
@@ -86,6 +88,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = []
+class Signal(BaseModel):
+ """Одна строка колонки сигналов реестра. Цвет несёт смысл только через
+ `severity`; текст обязателен и не заменяется цветом."""
+
+ kind: Literal["backlog", "refusals", "offline"]
+ severity: TimerState
+ text: str
+
+
class ActiveSessionOut(BaseModel):
session_id: UUID
trainee_name: str | None
@@ -101,6 +112,8 @@ class ActiveSessionOut(BaseModel):
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
+ signals: list[Signal] = []
+ presence_known: bool = True
paused: bool = False
@@ -185,6 +198,53 @@ async def dds_history(
return result
+def _signals(
+ state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool
+) -> list[Signal]:
+ """Сигналы реестра: очередь, повторные отказы, курсант не на связи.
+
+ Пауза занятия (`session.pause`) их не трогает и ложных срабатываний не
+ даёт: очередь на паузе не выдаёт карточки, команды станции отклоняются,
+ поэтому счётчики стоят; присутствие сокета — факт и на паузе.
+ Присутствие — по наличию подписчика в хабе этого узла; окно нужно, чтобы
+ короткий разрыв соединения не сразу считался потерей связи курсанта.
+
+ `live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
+ присутствие неизвестно. Чтение реестра не меняет состояние занятия.
+ """
+ settings = get_settings()
+ signals: list[Signal] = []
+ # Необработанная — без первичного статуса: он останавливает норматив
+ # DDS_ACK. Принятая или отклонённая карточка висит в очереди до
+ # «Следующей», но реакции курсанта уже не ждёт.
+ pending = sum(not card.timer_stopped for card in queue)
+ if pending >= settings.signal_backlog_threshold:
+ signals.append(Signal(
+ kind="backlog", severity=TimerState.WARN,
+ text=f"Очередь: {pending} необработанных карточек",
+ ))
+ if state.consecutive_refusals >= settings.signal_refusals_threshold:
+ signals.append(Signal(
+ kind="refusals", severity=TimerState.WARN,
+ text=f"Подряд отказов: {state.consecutive_refusals}",
+ ))
+ if live:
+ connected = (
+ hub.station_connected(state.session_id) if state.dds_phase
+ else hub.trainee_connected(state.session_id)
+ )
+ offline_seconds = (
+ (now - (state.socket_last_seen_at or state.started_at)).total_seconds()
+ if (state.socket_last_seen_at or state.started_at) is not None else 0
+ )
+ if not connected and offline_seconds >= settings.signal_offline_window_seconds:
+ signals.append(Signal(
+ kind="offline", severity=TimerState.VIOLATED,
+ text=f"Курсант не на связи {int(offline_seconds)} с",
+ ))
+ return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED)
+
+
@router.get("/active", response_model=list[ActiveSessionOut])
async def active(
request: Request,
@@ -198,6 +258,9 @@ async def active(
state.session_id: state
for state in hub.active_sessions(who.login)
}
+ #: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
+ #: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
+ checkpoint_only: set[UUID] = set()
if db is not None:
rows = (
await db.scalars(
@@ -226,6 +289,7 @@ async def active(
state.owner_login = row.owner_login
if not state.ended:
states[state.session_id] = state
+ checkpoint_only.add(state.session_id)
for state in states.values():
paused_ms = state.total_paused_ms
@@ -233,7 +297,7 @@ async def active(
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
if state.started_at else 0)
- station = state.station_snapshot() if state.exercise is Exercise.DDS else None
+ station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else []
card = state.desk.active
status_log = card.status_log if card is not None else []
@@ -268,6 +332,8 @@ async def active(
),
dds_statuses=latest_statuses,
dds_snapshot=station,
+ signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
+ presence_known=state.session_id not in checkpoint_only,
paused=state.paused,
))
return result
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 0ef1cee..37ac20b 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -14,7 +14,7 @@ from uuid import UUID, uuid4
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
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 (
BgStart,
CallEnded,
@@ -40,6 +40,7 @@ from app.domain.events import (
TraineeToServer,
)
from app.dialog.slots import TurnResult
+from app.domain.kio import PatchRejected, get_field
from app.domain.roles import Role
from app.scenarios import store
from app.session.dds import prepare_handoff_queue
@@ -193,6 +194,9 @@ async def _handle(session_id: UUID, state, event) -> None:
))
return
if state.paused:
+ # Отказ временный, в отличие от отклонённого значения: `kio.patch`
+ # без `auto`-отката. useCall держит правку в pending и повторяет её
+ # после `session.paused: false` — откат стёр бы ввод курсанта.
hub.to_trainee(session_id, ErrorEvent(
code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
))
@@ -279,16 +283,37 @@ async def _handle(session_id: UUID, state, event) -> None:
case "kio.patch":
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
- state.patch_kio(event.fields)
+ old_extra = list(state.kio.notify_extra)
+ try:
+ state.patch_kio(event.fields)
+ except (ValidationError, PatchRejected) as exc:
+ reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
+ # Недопустимое значение — отказ курсанту, а не сбой операции:
+ # сбой закрыл бы занятие на этом узле.
+ hub.to_trainee(session_id, ErrorEvent(
+ code=ErrorKind.UNSUPPORTED_EVENT,
+ message=f"Поле карточки не принято: {reason}"[:200],
+ ))
+ # Пакет отклонён целиком, а фронт держит его в pending до эха.
+ # Серверное значение с `auto` снимает pending — иначе на экране
+ # останутся правки, которых на сервере нет.
+ hub.to_trainee(session_id, KioPatchOut(
+ fields={path: get_field(state.kio, path) for path in event.fields},
+ source=PatchSource.AUTO,
+ ))
+ return
hub.to_trainee(session_id, KioPatchOut(
fields=event.fields, source=PatchSource.OPERATOR,
))
+ auto: dict = {}
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
- hub.to_trainee(session_id, KioPatchOut(
- fields={"incident_code": state.kio.incident_code,
- "notify": list(state.kio.notify)},
- source=PatchSource.AUTO,
- ))
+ auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
+ # Добавки сервер чистит сам (повторы, службы из notify) — фронту
+ # нужно итоговое значение, а не эхо присланного.
+ if state.kio.notify_extra != event.fields.get("notify_extra", old_extra):
+ auto["notify_extra"] = list(state.kio.notify_extra)
+ if auto:
+ hub.to_trainee(session_id, KioPatchOut(fields=auto, source=PatchSource.AUTO))
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
# посреди занятия дороже лишних килобайт.
hub.to_observers(session_id, KioState(kio=state.kio))
@@ -399,9 +424,14 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
)
if entered is None:
return
- _who, state = entered
+ who, state = entered
- with hub.trainee(session_id) as queue:
+ async with hub.trainee_socket(
+ session_id, station=False, trainee=who.role is Role.TRAINEE,
+ ) as queue:
+ if hub.is_lease_fenced(session_id):
+ await close_fenced(ws)
+ return
# Каждый новый канал получает серверное состояние, включая снятую паузу:
# клиент мог потерять событие resume во время переподключения.
queue.put_nowait(SessionPaused(paused=state.paused))
diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py
index 4dd821a..5cc764e 100644
--- a/backend/tests/test_checkpoint_model.py
+++ b/backend/tests/test_checkpoint_model.py
@@ -162,6 +162,9 @@ def full_state() -> SessionState:
resolve_comment="передано в другой регион",
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
+ consecutive_refusals=2,
+ socket_last_seen_at=AT,
+ socket_connected_at_checkpoint=True,
paused=True,
paused_at=AT,
total_paused_ms=15_000,
From 62d1010b79d5d6153c877c9edecee92ae13c08e7 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:15:04 +0300
Subject: [PATCH 32/41] =?UTF-8?q?chore:=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B5=20=D1=81=20main,=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?=
=?UTF-8?q?=D1=88=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=BE?=
=?UTF-8?q?=D0=B2=20=D0=B2=20sessions.py?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 72 ++++++++++++++++++++++++++++++--
1 file changed, 68 insertions(+), 4 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index 6379d08..0832ccf 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -8,6 +8,7 @@ import logging
import time
from collections.abc import AsyncIterator
from datetime import UTC, datetime
+from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
@@ -23,7 +24,7 @@ from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role
-from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
+from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.taxonomy import (
ERRORS,
ErrorCode,
@@ -32,7 +33,7 @@ from app.domain.taxonomy import (
FindingReview,
FindingSource,
)
-from app.domain.timers import TimerCode
+from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
from app.scoring.taxonomy import METRIC_MAP
@@ -41,7 +42,7 @@ from app.session.checkpoint import load_state
from app.session.finish import change_findings, override_score
from app.session.score import scoring_scenario
from app.session.hub import hub
-from app.session.state import now_utc
+from app.session.state import SessionState, now_utc
from app.session.store import (
FindingAdded,
FindingReviewed,
@@ -102,6 +103,15 @@ class DdsHistoryOut(BaseModel):
recipient_services: list[str] = []
+class Signal(BaseModel):
+ """Одна строка колонки сигналов реестра. Цвет несёт смысл только через
+ `severity`; текст обязателен и не заменяется цветом."""
+
+ kind: Literal["backlog", "refusals", "offline"]
+ severity: TimerState
+ text: str
+
+
class ActiveSessionOut(BaseModel):
session_id: UUID
trainee_name: str | None
@@ -117,6 +127,8 @@ class ActiveSessionOut(BaseModel):
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
dds_snapshot: StationSnapshot | None = None
+ signals: list[Signal] = []
+ presence_known: bool = True
def _out(session) -> SessionOut:
@@ -200,6 +212,52 @@ async def dds_history(
return result
+def _signals(
+ state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool
+) -> list[Signal]:
+ """Сигналы реестра: очередь, повторные отказы, курсант не на связи.
+
+ Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
+ отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
+ подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
+ соединения не сразу считался потерей связи курсанта.
+
+ `live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
+ присутствие неизвестно. Чтение реестра не меняет состояние занятия.
+ """
+ settings = get_settings()
+ signals: list[Signal] = []
+ # Необработанная — без первичного статуса: он останавливает норматив
+ # DDS_ACK. Принятая или отклонённая карточка висит в очереди до
+ # «Следующей», но реакции курсанта уже не ждёт.
+ pending = sum(not card.timer_stopped for card in queue)
+ if pending >= settings.signal_backlog_threshold:
+ signals.append(Signal(
+ kind="backlog", severity=TimerState.WARN,
+ text=f"Очередь: {pending} необработанных карточек",
+ ))
+ if state.consecutive_refusals >= settings.signal_refusals_threshold:
+ signals.append(Signal(
+ kind="refusals", severity=TimerState.WARN,
+ text=f"Подряд отказов: {state.consecutive_refusals}",
+ ))
+ if live:
+ connected = (
+ hub.station_connected(state.session_id) if state.dds_phase
+ else hub.trainee_connected(state.session_id)
+ )
+ offline_seconds = (
+ (now - (state.socket_last_seen_at or state.started_at)).total_seconds()
+ if (state.socket_last_seen_at or state.started_at) is not None else 0
+ )
+ if not connected and offline_seconds >= settings.signal_offline_window_seconds:
+ signals.append(Signal(
+ kind="offline", severity=TimerState.VIOLATED,
+ text=f"Курсант не на связи {int(offline_seconds)} с",
+ ))
+ return sorted(signals, key=lambda signal: signal.severity is not TimerState.VIOLATED)
+
+
@router.get("/active", response_model=list[ActiveSessionOut])
async def active(
request: Request,
@@ -213,6 +271,9 @@ async def active(
state.session_id: state
for state in hub.active_sessions(who.login)
}
+ #: Снимки без живой записи в хабе этого узла — чужой узел или сессия,
+ #: ожидающая восстановления. Её сокеты нельзя проверить этим процессом.
+ checkpoint_only: set[UUID] = set()
if db is not None:
rows = (
await db.scalars(
@@ -241,11 +302,12 @@ async def active(
state.owner_login = row.owner_login
if not state.ended:
states[state.session_id] = state
+ checkpoint_only.add(state.session_id)
for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds()))
if state.started_at else 0)
- station = state.station_snapshot() if state.exercise is Exercise.DDS else None
+ station = state.station_snapshot() if state.dds_phase else None
queue = station.queue_cards if station else []
card = state.desk.active
status_log = card.status_log if card is not None else []
@@ -278,6 +340,8 @@ async def active(
),
dds_statuses=latest_statuses,
dds_snapshot=station,
+ signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
+ presence_known=state.session_id not in checkpoint_only,
))
return result
From 77ec41693387a93d2952003eac86d6edf4add31c Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:15:17 +0300
Subject: [PATCH 33/41] =?UTF-8?q?chore:=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD?=
=?UTF-8?q?=D0=B8=D0=B5=20=D1=81=20main,=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5?=
=?UTF-8?q?=D1=88=D0=B5=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8?=
=?UTF-8?q?=D0=BA=D1=82=D1=8B=20=D0=B2=20partition=20smoke=20=D0=B8=20SCAL?=
=?UTF-8?q?E-OUT?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/test_cluster_failover.py | 123 +++++++++++++++++++++++++++----
1 file changed, 107 insertions(+), 16 deletions(-)
diff --git a/scripts/test_cluster_failover.py b/scripts/test_cluster_failover.py
index 6864f92..342d8ac 100644
--- a/scripts/test_cluster_failover.py
+++ b/scripts/test_cluster_failover.py
@@ -176,31 +176,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
while time.monotonic() < deadline:
try:
message = await asyncio.wait_for(socket.recv(), timeout=deadline - time.monotonic())
except TimeoutError:
- return {"closed": False, "fence_notice": False, "reason": "timeout"}
+ return outcome(closed=False, fence_notice=False, reason="timeout")
except Exception as exc:
close = getattr(exc, "rcvd", None) or getattr(exc, "sent", None)
close_code = getattr(close, "code", None) or getattr(exc, "code", None)
- return {
- "closed": True,
- "fence_notice": close_code == 1012,
- "close_code": close_code,
- "reason": getattr(close, "reason", "") if close else str(exc),
- }
+ return outcome(
+ closed=True,
+ fence_notice=close_code == 1012,
+ close_code=close_code,
+ reason=getattr(close, "reason", "") if close else str(exc),
+ )
if isinstance(message, str):
try:
event = json.loads(message)
except json.JSONDecodeError:
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-узлу; переподключитесь.":
- return {"closed": True, "fence_notice": True, "close_code": 1012,
- "reason": event.get("message")}
- return {"closed": False, "fence_notice": False, "reason": "timeout"}
+ return outcome(closed=True, fence_notice=True, close_code=1012,
+ reason=event.get("message"))
+ return outcome(closed=False, fence_notice=False, reason="timeout")
async def main(args: argparse.Namespace) -> int:
@@ -254,6 +285,11 @@ async def main(args: argparse.Namespace) -> int:
instructor_cookie = login(backend, instructor_login, instructor_password)
trainee_cookie = login(backend, trainee_login, trainee_password)
+ # Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
+ # может отдать узлу B до его сверки поколений. Ждать сверку не нужно:
+ # B проверяет неизвестный логин разовым SELECT (lct-42). Ожидание здесь
+ # лишило бы доказательности проверку ниже: опрос B сам вызвал бы разовую
+ # проверку, а за секунду B узнал бы логин сверкой.
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
"type": "scenario.start", "scenario_id": args.scenario,
"trainee": trainee.name, "trainee_id": str(trainee.id),
@@ -422,14 +458,37 @@ async def main(args: argparse.Namespace) -> int:
)
compose(args, "stop", proxy_name)
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()
result["checks"]["owner_process_running"] = owner_service in running_services
if owner_service not in running_services:
raise RuntimeError("owner process did not remain running during DB partition")
closures = await asyncio.gather(*(
- wait_for_fence(socket, timeout=args.takeover_timeout)
- for socket in active_sockets.values()
+ wait_for_fence(
+ 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_fence_notices"] = {
@@ -440,10 +499,27 @@ async def main(args: argparse.Namespace) -> int:
f"existing_{channel}_closed": closure["closed"]
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({
- 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()
})
+ result["checks"]["boundary_command_not_acked"] = (
+ result["existing_channel_closures"]["station"].get("command_acked") is False
+ )
deadline = time.monotonic() + args.takeover_timeout
takeover = None
@@ -479,12 +555,24 @@ async def main(args: argparse.Namespace) -> int:
except Exception as exc:
result["checks"]["old_owner_process_healthy"] = False
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(
old_backend.replace("http://", "ws://"),
f"/ws/control/{session_id}", instructor_cookie,
)
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(
f"{args.frontend_url}/api/sessions/{session_id}",
@@ -546,6 +634,9 @@ async def main(args: argparse.Namespace) -> int:
result["checks"]["boundary_command_reconciled"] = recovered_status in {
"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"] = []
if recovered_status == "responding":
recovered_snapshot = await station_command(ws_base, session_id, trainee_cookie, {
From 08e9d076f46fe1daf5292211fe33921f14059891 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:20:48 +0300
Subject: [PATCH 34/41] =?UTF-8?q?chore:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?=
=?UTF-8?q?=D1=82=D1=8B=20sessions.py=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B5=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81?=
=?UTF-8?q?=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=D0=BC=D0=B8=20main?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index 0832ccf..8947c35 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -23,8 +23,6 @@ from app.db import repo
from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
-from app.domain.roles import Role
-from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.taxonomy import (
ERRORS,
ErrorCode,
@@ -33,23 +31,22 @@ from app.domain.taxonomy import (
FindingReview,
FindingSource,
)
+from app.domain.roles import Role
+from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
from app.domain.timers import TimerCode, TimerState
from app.scoring.export import to_csv, to_pdf
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.checkpoint import load_state
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.hub import hub
-from app.session.state import SessionState, now_utc
-from app.session.store import (
- FindingAdded,
- FindingReviewed,
- ScoreOverridden,
- apply_score_override,
-)
-from app.scoring.review import has_finding
+from app.session.state import SessionState
+from app.session.store import ScoreOverridden, apply_score_override
from app.voice.recording import recording_path
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
From 20acef6ad8708624f3b1d6d6ee202c8f6b56d806 Mon Sep 17 00:00:00 2001
From: GGlamer <52128225+Gamer201760@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:23:05 +0300
Subject: [PATCH 35/41] =?UTF-8?q?chore:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?=
=?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D1=83=D0=B7=20=D0=B2=D0=BD=D0=B5=20=D1=81?=
=?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D0=B8=D0=B7=20main,=20=D1=87=D1=82?=
=?UTF-8?q?=D0=BE=D0=B1=D1=8B=20=D0=B2=D0=B5=D1=82=D0=BA=D1=83=20=D0=BE?=
=?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D1=81=D0=BB=D0=B8?=
=?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 13 ++++++-------
backend/app/api/ws/call.py | 6 +++---
backend/tests/test_checkpoint_model.py | 6 +++---
3 files changed, 12 insertions(+), 13 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index 1996e95..bebcc6c 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -111,10 +111,10 @@ class ActiveSessionOut(BaseModel):
dds_overdue_cards: int
dds_work_overdue_cards: int
dds_statuses: dict[str, str]
+ paused: bool = False
dds_snapshot: StationSnapshot | None = None
signals: list[Signal] = []
presence_known: bool = True
- paused: bool = False
def _out(session) -> SessionOut:
@@ -203,11 +203,10 @@ def _signals(
) -> list[Signal]:
"""Сигналы реестра: очередь, повторные отказы, курсант не на связи.
- Пауза занятия (`session.pause`) их не трогает и ложных срабатываний не
- даёт: очередь на паузе не выдаёт карточки, команды станции отклоняются,
- поэтому счётчики стоят; присутствие сокета — факт и на паузе.
- Присутствие — по наличию подписчика в хабе этого узла; окно нужно, чтобы
- короткий разрыв соединения не сразу считался потерей связи курсанта.
+ Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
+ отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
+ подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
+ соединения не сразу считался потерей связи курсанта.
`live=False` — снимок чужого узла: его сокеты здесь не видны, поэтому
присутствие неизвестно. Чтение реестра не меняет состояние занятия.
@@ -331,10 +330,10 @@ async def active(
for card in state.desk.cards.values()
),
dds_statuses=latest_statuses,
+ paused=state.paused,
dds_snapshot=station,
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
presence_known=state.session_id not in checkpoint_only,
- paused=state.paused,
))
return result
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 37ac20b..78387ff 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -432,9 +432,6 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
- # Каждый новый канал получает серверное состояние, включая снятую паузу:
- # клиент мог потерять событие resume во время переподключения.
- queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@@ -468,6 +465,9 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state, initial_statement=False)
+ # Каждый новый канал получает серверное состояние, включая снятую паузу:
+ # клиент мог потерять событие resume во время переподключения.
+ queue.put_nowait(SessionPaused(paused=state.paused))
writer = asyncio.create_task(pump(ws, queue))
try:
while True:
diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py
index 5cc764e..90cead6 100644
--- a/backend/tests/test_checkpoint_model.py
+++ b/backend/tests/test_checkpoint_model.py
@@ -160,14 +160,14 @@ def full_state() -> SessionState:
desk=desk,
resolved_outcome="справка",
resolve_comment="передано в другой регион",
+ paused=True,
+ paused_at=AT,
+ total_paused_ms=15_000,
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2,
socket_last_seen_at=AT,
socket_connected_at_checkpoint=True,
- paused=True,
- paused_at=AT,
- total_paused_ms=15_000,
)
From 6e7c4f4596b05b1193e3446662a4b591b952f055 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 23:33:46 +0300
Subject: [PATCH 36/41] =?UTF-8?q?fix:=20=D0=BF=D0=B0=D1=83=D0=B7=D0=B0=20?=
=?UTF-8?q?=D0=B8=D1=81=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B0=20=D0=B8?=
=?UTF-8?q?=D0=B7=20=D1=80=D0=B5=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BD?=
=?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/ws/call.py | 5 ++-
backend/app/scoring/dispatcher.py | 26 ++++++++++----
backend/app/session/finish.py | 2 ++
backend/app/session/score.py | 5 +++
backend/app/session/state.py | 7 +++-
backend/tests/test_checkpoint_model.py | 1 +
backend/tests/test_dispatcher_scoring.py | 21 +++++++++--
backend/tests/test_session_pause.py | 46 ++++++++++++++++++++++--
8 files changed, 97 insertions(+), 16 deletions(-)
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 78387ff..bb867be 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -432,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
+ # Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
+ queue.put_nowait(SessionPaused(paused=state.paused))
if state.exercise is Exercise.CARD:
from app.api.ws.control import card_briefing
@@ -465,9 +467,6 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
if state.recorder is None:
state.recorder = start_recording(session_id)
_start_voice(session_id, state, initial_statement=False)
- # Каждый новый канал получает серверное состояние, включая снятую паузу:
- # клиент мог потерять событие resume во время переподключения.
- queue.put_nowait(SessionPaused(paused=state.paused))
writer = asyncio.create_task(pump(ws, queue))
try:
while True:
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 0342234..2b3f3fd 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -12,6 +12,8 @@
"""
import math
+from collections.abc import Sequence
+from datetime import datetime
from app.domain.statuses import (
COMMENT_REQUIRED,
@@ -68,6 +70,7 @@ TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
@@ -84,15 +87,22 @@ def report_reactions(
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,
- ))
+ if after is None:
+ result.append((report, None))
+ continue
+ elapsed_ms = int((after - report.at).total_seconds() * 1000)
+ for start, end in pause_intervals or ():
+ overlap_start = max(report.at, start)
+ overlap_end = min(after, end)
+ if overlap_end > overlap_start:
+ elapsed_ms -= int((overlap_end - overlap_start).total_seconds() * 1000)
+ result.append((report, max(0, elapsed_ms)))
return result
def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> tuple[Metric, Finding | None] | None:
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
@@ -100,7 +110,7 @@ def report_reaction(
где отметки нет. None — реагировать было не на что.
"""
refused = current(marks, service) in TERMINAL_REFUSAL
- reactions = [(report, delay) for report, delay in report_reactions(marks, reports)
+ reactions = [(report, delay) for report, delay in report_reactions(marks, reports, pause_intervals)
if delay is not None or not refused]
if not reactions:
return None
@@ -190,6 +200,7 @@ def evaluate_dispatcher(
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@@ -381,7 +392,7 @@ def evaluate_dispatcher(
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
- reaction = report_reaction(service, marks, reports, report_reaction_ms)
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
@@ -396,6 +407,7 @@ def dispatcher_metrics(
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
+ pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@@ -429,7 +441,7 @@ def dispatcher_metrics(
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
- reaction = report_reaction(service, marks, reports, report_reaction_ms)
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None:
metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index c7a30d9..9f07d04 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -61,10 +61,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
+ pause_intervals=state.pause_intervals,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services, report_reaction_ms=reaction_ms,
+ pause_intervals=state.pause_intervals,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
diff --git a/backend/app/session/score.py b/backend/app/session/score.py
index 2ef6501..873671e 100644
--- a/backend/app/session/score.py
+++ b/backend/app/session/score.py
@@ -8,6 +8,7 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
+from datetime import datetime
from typing import Any
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
@@ -68,6 +69,7 @@ class ScoringSnapshot:
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
call_card: DdsLiveCard | None
call_card_services: tuple[str, ...]
+ pause_intervals: tuple[tuple[datetime, datetime], ...]
@property
def reports_cards(self) -> bool:
@@ -112,6 +114,7 @@ class ScoringSnapshot:
call_card=live_card.model_copy(deep=True) if live_card is not None else None,
call_card_services=(tuple(state.card_services(live_card))
if live_card is not None else ()),
+ pause_intervals=tuple(state.pause_intervals),
)
@@ -231,10 +234,12 @@ async def score_session(
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
+ pause_intervals=snapshot.pause_intervals,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
+ pause_intervals=snapshot.pause_intervals,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 1f2c850..5c99a5c 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -111,6 +111,8 @@ class PersistedSession(BaseModel):
paused_at: datetime | None = None
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
total_paused_ms: int = 0
+ #: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
+ pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
#: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None
dispatched_at: datetime | None = None
@@ -218,8 +220,11 @@ class SessionState(PersistedSession):
for card in self.desk.cards.values():
card.timers.resume(now)
if self.paused_at is not None:
- idle = now_utc() - self.paused_at
+ resumed_at = now_utc()
+ idle = resumed_at - self.paused_at
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
+ if resumed_at > self.paused_at:
+ self.pause_intervals.append((self.paused_at, resumed_at))
if self.desk.next_arrival_at is not None:
self.desk.next_arrival_at += idle
self.paused = False
diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py
index 90cead6..5032577 100644
--- a/backend/tests/test_checkpoint_model.py
+++ b/backend/tests/test_checkpoint_model.py
@@ -163,6 +163,7 @@ def full_state() -> SessionState:
paused=True,
paused_at=AT,
total_paused_ms=15_000,
+ pause_intervals=[(AT - timedelta(seconds=20), AT - timedelta(seconds=5))],
processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2,
diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py
index f4ba2df..d6a1d7c 100644
--- a/backend/tests/test_dispatcher_scoring.py
+++ b/backend/tests/test_dispatcher_scoring.py
@@ -316,7 +316,7 @@ def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
text="доклад", at=at_second(seconds))
-def reaction(status_log, reports, limit_ms=45_000):
+def reaction(status_log, reports, limit_ms=45_000, pause_intervals=None):
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
dispatched_at=T0)
card.phone_reports = reports
@@ -324,9 +324,11 @@ def reaction(status_log, reports, limit_ms=45_000):
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,
+ pause_intervals=pause_intervals,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
- report_reaction_ms=limit_ms)
+ report_reaction_ms=limit_ms,
+ pause_intervals=pause_intervals)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
@@ -364,6 +366,21 @@ def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
assert metric.credit == time_credit(70_000, 45_000)
+def test_pause_between_report_and_status_does_not_count_as_reaction_time():
+ marks = full_run(arrived_at=180)
+ reports = [crew_report("arrived", 100)]
+ pause = [(at_second(110), at_second(170))]
+
+ without_pause, late = reaction(marks, reports)
+ with_pause, active = reaction(marks, reports, pause_intervals=pause)
+
+ assert [finding.code for finding in without_pause] == [ErrorCode.D6]
+ assert not late.passed
+ assert with_pause == []
+ assert active.passed and active.credit == time_credit(20_000, 45_000)
+ assert "20 с" in active.fact
+
+
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(
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
index cf522a4..4f662ba 100644
--- a/backend/tests/test_session_pause.py
+++ b/backend/tests/test_session_pause.py
@@ -7,7 +7,7 @@
import asyncio
import time
-from datetime import timedelta
+from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
@@ -41,14 +41,14 @@ from app.domain.events import (
TtsEnd,
)
from app.domain.roles import Role
-from app.domain.statuses import ServiceStatus
+from app.domain.statuses import PhoneReportRecord, ServiceStatus
from app.domain.timers import TimerCode
from app.main import app
from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics
from app.session import timers as timers_module
from app.session.checkpoint import dump_state, load_state
-from app.session.finish import end_session
+from app.session.finish import end_session, score_dds_card
from app.session.hub import hub
from app.session.state import SessionState, now_utc
from app.session.store import LessonPaused, LessonResumed, MemorySessionStore
@@ -621,9 +621,49 @@ def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
assert sum(isinstance(record, LessonResumed) for record in records) == 1
assert [event.paused for event in events if isinstance(event, SessionPaused)] == [True, False]
assert state.total_paused_ms == 60_000
+ assert state.pause_intervals == [(base, base + timedelta(seconds=60))]
assert card.timers.timers[TimerCode.DDS_ACK].current_ms(clock.now) == 15_000
+def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
+ base = datetime(2026, 9, 27, 10, tzinfo=UTC)
+ state, card = _dds_state_with_active_card(clock)
+ service = state.card_services(card)[0]
+ monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
+ card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")
+ card.phone_reports.append(PhoneReportRecord(
+ service=service, crew="Бригада 12", phase="dispatched",
+ text="Бригада выехала", at=base,
+ ))
+ store = MemorySessionStore()
+ monkeypatch.setattr(hub, "store", store)
+ monkeypatch.setitem(hub._sessions, state.session_id, state)
+
+ async def command(event):
+ async with hub.operation(state.session_id):
+ await control_ws._command(state.session_id, event, TEACHER)
+
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=10))
+ asyncio.run(command(SessionPause()))
+ restored = load_state(dump_state(state), base + timedelta(seconds=70))
+ assert restored.paused and restored.pause_intervals == []
+ monkeypatch.setitem(hub._sessions, state.session_id, restored)
+ monkeypatch.setattr("app.session.state.now_utc", lambda: base + timedelta(seconds=70))
+ asyncio.run(command(SessionResume()))
+ monkeypatch.setattr("app.session.dds.now_utc", lambda: base + timedelta(seconds=80))
+ restored.desk.active.set_service_status(
+ service, ServiceStatus.RESPONDING, "Основание: доклад. Сведения: бригада выехала.",
+ )
+
+ result = score_dds_card(restored, restored.desk.active)
+ reaction = next(item for item in result.metrics if item.key == "dds_report_reaction")
+ assert restored.pause_intervals == [
+ (base + timedelta(seconds=10), base + timedelta(seconds=70)),
+ ]
+ assert reaction.passed and "20 с" in reaction.fact
+ assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
+
+
def test_pause_is_ignored_in_ended_session(clock, monkeypatch):
state, _card = _dds_state_with_active_card(clock)
store = MemorySessionStore()
From 5e459c449b6b42a90f8ebba3b7771472bfa152e7 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 23:37:57 +0300
Subject: [PATCH 37/41] =?UTF-8?q?fix:=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?=
=?UTF-8?q?=D1=82=D0=B8=D0=B2=20=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4=D0=B0?=
=?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D0=B2?=
=?UTF-8?q?=D1=8B=D0=B1=D0=BE=D1=80=D1=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/domain/events.py | 1 +
backend/app/scoring/dispatcher.py | 16 ++++++----
backend/app/scoring/export.py | 7 +++--
backend/app/session/finish.py | 2 ++
backend/app/session/score.py | 2 ++
backend/tests/test_dds_exercise.py | 7 +++--
backend/tests/test_dispatcher_scoring.py | 2 ++
backend/tests/test_report_export.py | 13 +++++++-
backend/tests/test_session_checkpoint.py | 27 ++++++++++++++++
backend/tests/test_session_pause.py | 1 +
frontend/src/features/debrief/Debrief.tsx | 4 ++-
.../instructor/ScoreWeightsEditor.tsx | 7 +++++
frontend/src/pages/instructor/Instructor.tsx | 31 +++++++++++++------
frontend/src/shared/types/generated.ts | 1 +
14 files changed, 100 insertions(+), 21 deletions(-)
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 05c4279..9965d1d 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -54,6 +54,7 @@ class LessonCriteria(BaseModel):
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_report_reaction_enabled: bool = False
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 2b3f3fd..84485c2 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -200,6 +200,7 @@ def evaluate_dispatcher(
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
+ report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@@ -392,9 +393,10 @@ def evaluate_dispatcher(
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
- reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
- if reaction is not None and reaction[1] is not None:
- findings.append(reaction[1])
+ if report_reaction_enabled:
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
+ if reaction is not None and reaction[1] is not None:
+ findings.append(reaction[1])
return findings
@@ -407,6 +409,7 @@ def dispatcher_metrics(
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
+ report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@@ -441,9 +444,10 @@ def dispatcher_metrics(
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
- reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
- if reaction is not None:
- metrics.append(reaction[0])
+ if report_reaction_enabled:
+ reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
+ if reaction is not None:
+ metrics.append(reaction[0])
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
else ServiceStatus.DECLINED)
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index cc03471..13a97c2 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -55,7 +55,8 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
- report.criteria.dds_report_reaction_limit_seconds)
+ report.criteria.dds_report_reaction_limit_seconds
+ if report.criteria.dds_report_reaction_enabled else "не применяется")
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
@@ -172,7 +173,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
- ("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
+ ("Лимит реакции на доклад бригады",
+ f"{report.criteria.dds_report_reaction_limit_seconds} с"
+ if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 9f07d04..e8c5efc 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -61,11 +61,13 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
+ report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services, report_reaction_ms=reaction_ms,
+ report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
weighted = GostResult(metrics=metrics, findings=findings)
diff --git a/backend/app/session/score.py b/backend/app/session/score.py
index 873671e..35fe772 100644
--- a/backend/app/session/score.py
+++ b/backend/app/session/score.py
@@ -234,11 +234,13 @@ async def score_session(
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
+ report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
+ report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index 70a4e49..68462c0 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -455,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
+ "dds_report_reaction_enabled": False,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
@@ -749,7 +750,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
- session_id, control = start(client)
+ session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@@ -835,6 +836,7 @@ def send_status(station, service, status):
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_enabled": True,
"dds_report_reaction_limit_seconds": 20,
"score_weights": {"dds_report_reaction": 3.5},
})
@@ -876,6 +878,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
+ assert report["criteria"]["dds_report_reaction_enabled"] is True
assert [item for item in report["metrics"]
if item["key"] == "dds_report_reaction"] == metrics
if reaction == "slow":
@@ -889,7 +892,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
- session_id, control = start(client)
+ session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
diff --git a/backend/tests/test_dispatcher_scoring.py b/backend/tests/test_dispatcher_scoring.py
index d6a1d7c..d985463 100644
--- a/backend/tests/test_dispatcher_scoring.py
+++ b/backend/tests/test_dispatcher_scoring.py
@@ -324,10 +324,12 @@ def reaction(status_log, reports, limit_ms=45_000, pause_intervals=None):
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,
+ report_reaction_enabled=True,
pause_intervals=pause_intervals,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms,
+ report_reaction_enabled=True,
pause_intervals=pause_intervals)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py
index 0ee91f4..1c56ea8 100644
--- a/backend/tests/test_report_export.py
+++ b/backend/tests/test_report_export.py
@@ -68,11 +68,21 @@ 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] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
- assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
+ assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
+def test_csv_shows_enabled_report_reaction_limit():
+ report = sample_report()
+ report.criteria.dds_report_reaction_enabled = True
+ report.criteria.dds_report_reaction_limit_seconds = 20
+
+ rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
+
+ assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
+
+
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-")
@@ -91,6 +101,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
assert "Лимит реакции на доклад бригады" in extracted
+ assert "не применяется" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):
diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py
index e572a22..326080a 100644
--- a/backend/tests/test_session_checkpoint.py
+++ b/backend/tests/test_session_checkpoint.py
@@ -88,6 +88,7 @@ def test_active_dds_session_round_trips_without_losing_work():
def test_report_reaction_keeps_times_and_limit_after_restore():
before = dds_state()
+ before.criteria.dds_report_reaction_enabled = True
before.criteria.dds_report_reaction_limit_seconds = 20
card = before.desk.active
service = before.card_services(card)[0]
@@ -102,6 +103,7 @@ def test_report_reaction_keeps_times_and_limit_after_restore():
restored = load_state(dump_state(before), now_utc())
assert restored.criteria.dds_report_reaction_limit_seconds == 20
+ assert restored.criteria.dds_report_reaction_enabled
assert restored.desk.active.phone_reports == card.phone_reports
def reaction(state):
@@ -118,6 +120,31 @@ def test_report_reaction_keeps_times_and_limit_after_restore():
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
+def test_report_reaction_default_off_keeps_status_completeness_d6():
+ state = dds_state()
+ card = state.desk.active
+ service = state.card_services(card)[0]
+ card.set_service_status(service, ServiceStatus.RESPONDING,
+ "Основание: доклад.\nСведения: бригада выехала.")
+ without_report = state.model_copy(deep=True)
+ card.phone_reports.append(PhoneReportRecord(
+ service=service, crew=card.crew_selected, phase="dispatched",
+ text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70),
+ ))
+
+ result = score_dds_card(state, card)
+ baseline = score_dds_card(without_report, without_report.desk.active)
+
+ assert state.criteria.dds_report_reaction_enabled is False
+ assert result.score_auto == baseline.score_auto
+ assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [
+ (item.code, item.metric_key, item.fact) for item in baseline.findings
+ ]
+ assert not any(item.key == "dds_report_reaction" for item in result.metrics)
+ assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
+ assert any(item.code is ErrorCode.D6 for item in result.findings)
+
+
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),
diff --git a/backend/tests/test_session_pause.py b/backend/tests/test_session_pause.py
index 4f662ba..88f89a0 100644
--- a/backend/tests/test_session_pause.py
+++ b/backend/tests/test_session_pause.py
@@ -628,6 +628,7 @@ def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
base = datetime(2026, 9, 27, 10, tzinfo=UTC)
state, card = _dds_state_with_active_card(clock)
+ state.criteria.dds_report_reaction_enabled = True
service = state.card_services(card)[0]
monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index cd2fd92..69f6eb7 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -83,7 +83,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${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.criteria.dds_report_reaction_enabled
+ ? ` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`
+ : " не применяется"}>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
>}.
diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
index 94102ab..f1cc62d 100644
--- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx
+++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
@@ -47,10 +47,12 @@ const REVIEW_METRICS: MetricOption[] = [
export function ScoreWeightsEditor({
exercise,
+ reportReactionEnabled,
values,
onChange,
}: {
exercise: LessonExercise;
+ reportReactionEnabled: boolean;
values: Record;
onChange: (weights: Record) => void;
}) {
@@ -63,6 +65,11 @@ export function ScoreWeightsEditor({
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
groups.push(["Разбор", REVIEW_METRICS]);
+ if (!reportReactionEnabled) {
+ for (const group of groups) {
+ group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction");
+ }
+ }
function setWeight(key: string, raw: string) {
const next = { ...values };
diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx
index b362a7f..65bb74a 100644
--- a/frontend/src/pages/instructor/Instructor.tsx
+++ b/frontend/src/pages/instructor/Instructor.tsx
@@ -93,6 +93,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
+ const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
@@ -292,6 +293,7 @@ export function Instructor() {
&& !(exercise === "dds" && wholeTicket)
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
: undefined;
+ const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled;
const sent = control.start(sessionId, {
scenarioId: primaryScenarioId,
mode,
@@ -308,15 +310,18 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
+ dds_report_reaction_enabled: reportReactionEnabled,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
- score_weights: lessonScoreWeights,
+ score_weights: reportReactionEnabled
+ ? lessonScoreWeights
+ : Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")),
},
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
- exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
+ exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@@ -664,13 +669,20 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.
}
- {(exercise === "dds" || exercise === "card_dds") &&
- Реакция на доклад бригады, секунд
-
- По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.
- }
+ {(exercise === "dds" || exercise === "card_dds") && <>
+
+ setDdsReportReactionEnabled(event.target.checked)} />
+ Оценивать срок отметки статуса после доклада бригады
+
+ {ddsReportReactionEnabled &&
+ Лимит реакции на доклад бригады, секунд
+
+ Учебный лимит при включённом критерии; исходное значение — 45 секунд.
+ }
+ >}
Допустимо нарушенных метрик
}
Нормативы ГОСТ для приёма вызова не изменяются.
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index b223e3c..3e8d32c 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -400,6 +400,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
+ dds_report_reaction_enabled?: boolean;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
From f6976fe7bd163eab1722a8b5cc60e8aab710bf14 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 23:30:59 +0300
Subject: [PATCH 38/41] =?UTF-8?q?docs:=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B1?=
=?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D1=83=D1=81=D0=BB=D0=BE=D0=B2=D0=B8?=
=?UTF-8?q?=D1=8F=20=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5=D1=89=D0=B5=D0=BD=D0=B8?=
=?UTF-8?q?=D1=8F=20=D0=95=D0=9A=D0=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/tests/test_ekp.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index e641fce..1abcd3d 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -4,6 +4,8 @@
производен и не выбирается руками, сценарий размечается признаками.
"""
+import hashlib
+import json
from pathlib import Path
import pytest
@@ -93,6 +95,31 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
}
+def test_ticket_ekp_codes_and_notify_lists_match_current_baseline():
+ """Изменение правил оповещения не должно незаметно переписать 96 эталонов.
+
+ При намеренной смене версии ЕКП сначала сверяют каждый изменившийся список
+ с источником и методистом, затем обновляют контрольную сумму.
+ """
+ cards = [
+ scenario
+ for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
+ if scenario.ticket is not None
+ ]
+ assert len(cards) == 96
+ manifest = {
+ scenario.id: {
+ "code": scenario.ground_truth.incident_code,
+ "notify": scenario.ground_truth.notify,
+ }
+ for scenario in cards
+ }
+ encoded = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
+ assert hashlib.sha256(encoded.encode()).hexdigest() == (
+ "2e3d49cc1e4a47bf8813437207a4105799c5721cc54bddc795730267ebb44df4"
+ )
+
+
def test_ticket_two_preserves_moscow_and_does_not_invent_missing_victim_data():
root = Path(__file__).resolve().parents[2] / "scenarios"
cards = {
From 68361c66e05136aec6f61bc094850a9ec58406f6 Mon Sep 17 00:00:00 2001
From: kaifarikman
Date: Sun, 27 Sep 2026 23:38:20 +0300
Subject: [PATCH 39/41] =?UTF-8?q?feat:=20=D1=83=D1=87=D0=B8=D1=82=D1=8B?=
=?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BD?=
=?UTF-8?q?=D0=B0=D1=80=D1=83=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20?=
=?UTF-8?q?=D0=9A=D0=98=D0=9E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/domain/kio.py | 14 +++++++--
backend/tests/test_ekp.py | 37 ++++++++++++++++++++++++
frontend/src/features/kio-card/fields.ts | 1 +
frontend/src/shared/types/generated.ts | 1 +
4 files changed, 51 insertions(+), 2 deletions(-)
diff --git a/backend/app/domain/kio.py b/backend/app/domain/kio.py
index a8158a8..6760c27 100644
--- a/backend/app/domain/kio.py
+++ b/backend/app/domain/kio.py
@@ -112,6 +112,7 @@ class KIO(BaseModel):
description: str | None = None
victims_count: int | None = None
is_emergency: bool = False
+ offence_signs: bool | None = None
life_threat: bool = False
evacuation_needed: bool = False
@@ -153,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"caller_name", "caller_contact", "phone_on_scene", "language",
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
- "incident_type", "victims_count", "is_emergency", "life_threat",
+ "incident_type", "victims_count", "is_emergency", "offence_signs", "life_threat",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
@@ -193,7 +194,10 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
#: те же «признаки», только не про тип происшествия, а про обстоятельства:
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
MODIFIER_FIELDS: frozenset[str] = frozenset(
- {"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
+ {
+ "incident_group", "signs", "victims_count", "life_threat",
+ "evacuation_needed", "fire.gasified", "offence_signs", "police.offence_kind",
+ }
)
@@ -208,6 +212,12 @@ def modifiers(card: KIO) -> list[str]:
keys.append("evacuation")
if card.fire is not None and card.fire.gasified:
keys.append("gas")
+ if card.offence_signs is True or (
+ card.police is not None
+ and card.police.offence_kind is not None
+ and bool(card.police.offence_kind.strip())
+ ):
+ keys.append("offence")
return keys
diff --git a/backend/tests/test_ekp.py b/backend/tests/test_ekp.py
index 1abcd3d..5ad08ad 100644
--- a/backend/tests/test_ekp.py
+++ b/backend/tests/test_ekp.py
@@ -409,6 +409,43 @@ def test_gasified_object_brings_mosgaz():
assert "МОСГАЗ" in card.notify
+def test_explicit_offence_signs_add_police_and_removal_recalculates_notify():
+ from app.domain.kio import apply_patch, modifiers
+
+ signs = ["на улице", "мусор", "открытое пламя"]
+ base = derive_incident(KIO(signs=signs))
+ assert base.incident_code == "1010101"
+ assert "МВД" not in base.notify
+
+ stated = apply_patch(base, {"offence_signs": True})
+ assert "offence" in modifiers(stated)
+ assert set(stated.notify) == set(base.notify) | {"МВД"}
+
+ denied = apply_patch(stated, {"offence_signs": False})
+ assert denied.notify == base.notify
+ unknown = apply_patch(stated, {"offence_signs": None})
+ assert unknown.notify == base.notify
+
+
+def test_offence_kind_also_adds_police_until_both_sources_are_removed():
+ from app.domain.kio import apply_patch, modifiers
+
+ base = derive_incident(KIO(signs=["на улице", "мусор", "открытое пламя"]))
+ stated = apply_patch(base, {"police.offence_kind": "поджог"})
+ assert "offence" in modifiers(stated)
+ assert set(stated.notify) == set(base.notify) | {"МВД"}
+
+ denied_flag = apply_patch(stated, {"offence_signs": False})
+ assert "МВД" in denied_flag.notify, "явно указанный вид правонарушения остаётся источником"
+ with_both = apply_patch(stated, {"offence_signs": True})
+ assert modifiers(with_both).count("offence") == 1
+ without_kind = apply_patch(with_both, {"police.offence_kind": " "})
+ assert "МВД" in without_kind.notify
+ without_flag = apply_patch(without_kind, {"offence_signs": False})
+ assert without_flag.notify == base.notify
+ assert "offence" not in modifiers(without_flag)
+
+
def test_removed_modifier_recalculates_notify_without_stale_service():
from app.domain.kio import KIO, FireDetails, apply_patch, derive_incident
diff --git a/frontend/src/features/kio-card/fields.ts b/frontend/src/features/kio-card/fields.ts
index 26f73e3..a5c6848 100644
--- a/frontend/src/features/kio-card/fields.ts
+++ b/frontend/src/features/kio-card/fields.ts
@@ -97,6 +97,7 @@ export const GROUPS: FieldGroup[] = [
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
{ path: "victims_count", label: "Пострадавших", kind: "number" },
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" },
+ { path: "offence_signs", label: "Признаки правонарушения", kind: "bool" },
{ path: "life_threat", label: "Угроза жизни", kind: "bool" },
{ path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" },
],
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 3e8d32c..47e00a8 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -360,6 +360,7 @@ export interface KIO {
description?: string | null;
victims_count?: number | null;
is_emergency?: boolean;
+ offence_signs?: boolean | null;
life_threat?: boolean;
evacuation_needed?: boolean;
/** Список оповещения */
From bfaadd556cc3f8a3ebd7b65bd1b3921846f4ad4b Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 23:41:15 +0300
Subject: [PATCH 40/41] =?UTF-8?q?lct-51:=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81?=
=?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?=
=?UTF-8?q?=D1=82=20=D0=94=D0=94=D0=A1=20=E2=80=94=20=D0=B2=D1=8B=D1=88?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D1=8F=D1=89=D0=B8=D0=B9=20=D0=BD=D0=B0?=
=?UTF-8?q?=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA=20=D1=87=D0=B5=D1=80?=
=?UTF-8?q?=D0=B5=D0=B7=20WebRTC=206103=20=D1=81=20=D0=B7=D0=B0=D0=BF?=
=?UTF-8?q?=D0=B8=D1=81=D1=8C=D1=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 1 +
Makefile | 2 +-
docker-compose.sip.yml | 1 +
frontend/src/features/sip/SipPhone.tsx | 13 ++++++++++++-
frontend/src/pages/dds/Dds.tsx | 6 ++++--
frontend/src/pages/phone/Phone.tsx | 19 ++++++++++++++++---
sip/entrypoint.sh | 7 +++++--
sip/extensions.conf | 7 ++++---
sip/pjsip.conf.template | 19 +++++++++++++++++++
9 files changed, 63 insertions(+), 12 deletions(-)
diff --git a/.env.example b/.env.example
index 486bd1a..1b64d3b 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,7 @@ SIP_6002_PASSWORD=
SIP_6003_PASSWORD=
SIP_6101_PASSWORD=
SIP_6102_PASSWORD=
+SIP_6103_PASSWORD=
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
diff --git a/Makefile b/Makefile
index ca17c3b..58e5f4c 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
offline-sip: ## Поднять SIP из уже собранного образа без сети
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
-sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
+sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6103
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
diff --git a/docker-compose.sip.yml b/docker-compose.sip.yml
index 25f5160..79d23e6 100644
--- a/docker-compose.sip.yml
+++ b/docker-compose.sip.yml
@@ -9,6 +9,7 @@ services:
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
+ SIP_6103_PASSWORD: ${SIP_6103_PASSWORD:-}
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START: ${SIP_RTP_START:-10000}
SIP_RTP_END: ${SIP_RTP_END:-10099}
diff --git a/frontend/src/features/sip/SipPhone.tsx b/frontend/src/features/sip/SipPhone.tsx
index 74e506b..4e68a1d 100644
--- a/frontend/src/features/sip/SipPhone.tsx
+++ b/frontend/src/features/sip/SipPhone.tsx
@@ -12,12 +12,19 @@ const LABEL: Record = {
error: "ошибка",
};
+/** Абонент быстрого набора: пульт ДДС звонит бригаде и начальнику одной кнопкой. */
+export interface SipContact {
+ label: string;
+ number: string;
+}
+
export function SipPhone({
- defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC",
+ defaultExtension = "6101", defaultTarget = "6102", title = "Локальный SIP/WebRTC", contacts = [],
}: {
defaultExtension?: string;
defaultTarget?: string;
title?: string;
+ contacts?: SipContact[];
}) {
const phone = useSipPhone();
const [extension, setExtension] = useState(defaultExtension);
@@ -46,6 +53,10 @@ export function SipPhone({
Номер собеседника setTarget(event.target.value)} />
{phone.status === "registered" && phone.dial(target)}>Позвонить }
+ {phone.status === "registered" && contacts.map((contact) => { setTarget(contact.number); phone.dial(contact.number); }}>
+ Позвонить: {contact.label} · {contact.number}
+ )}
{phone.status === "incoming" && Ответить · {phone.peer} }
{inCall && phone.status !== "incoming" && {phone.peer || target} }
{inCall && Завершить }
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 59267b6..fbe6e47 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -604,9 +604,11 @@ export function Dds() {
}>
-
+
- На втором рабочем месте откройте телефон старшего группы и зарегистрируйте 6102. Пароли 6101/6102 выдаёт локальный администратор командой make sip-credentials.
+ На втором рабочем месте откройте телефон старшего группы (6102); преподаватель в роли вышестоящего начальника — телефон 6103 . Пароли 6101–6103 выдаёт локальный администратор командой make sip-credentials.
Учебный протокол доклада
Связь используется при необходимости. Разговор и учебный доклад сохраняются в журнале, но не блокируют изменение статуса.
Дежурная бригада
diff --git a/frontend/src/pages/phone/Phone.tsx b/frontend/src/pages/phone/Phone.tsx
index bf767b0..cff22a4 100644
--- a/frontend/src/pages/phone/Phone.tsx
+++ b/frontend/src/pages/phone/Phone.tsx
@@ -1,9 +1,22 @@
import { SipPhone } from "@/features/sip/SipPhone";
+// Второе рабочее место голосовой связи ДДС. Старший группы — по умолчанию;
+// вышестоящего начальника (?role=superior) играет преподаватель: это
+// обязательный маршрут по ответу заказчика (П.7).
+const ROLES = {
+ crew: { title: "Телефон старшего группы", extension: "6102", heading: "IP-телефон старшего группы" },
+ superior: { title: "Телефон вышестоящего начальника", extension: "6103", heading: "IP-телефон вышестоящего начальника" },
+} as const;
+
export function Phone() {
+ const role = new URLSearchParams(window.location.search).get("role") === "superior" ? "superior" : "crew";
+ const { title, extension, heading } = ROLES[role];
return
- IP-телефон старшего группы
- Откройте этот экран на втором рабочем месте, зарегистрируйте номер 6102 и примите вызов ДДС 6101.
-
+ {heading}
+ Откройте этот экран на втором рабочем месте, зарегистрируйте номер {extension} и примите вызов ДДС 6101.
+
+ {role === "crew"
+ ? <>Роль вышестоящего начальника — телефон 6103 .>
+ : <>Роль старшего группы — телефон 6102 .>}
;
}
diff --git a/sip/entrypoint.sh b/sip/entrypoint.sh
index b5bcfca..32ab598 100644
--- a/sip/entrypoint.sh
+++ b/sip/entrypoint.sh
@@ -10,6 +10,7 @@ override_6002=${SIP_6002_PASSWORD:-}
override_6003=${SIP_6003_PASSWORD:-}
override_6101=${SIP_6101_PASSWORD:-}
override_6102=${SIP_6102_PASSWORD:-}
+override_6103=${SIP_6103_PASSWORD:-}
if [ -s "$credentials" ]; then
# Файл содержит только сгенерированные hex-значения без shell-конструкций.
. "$credentials"
@@ -19,6 +20,7 @@ SIP_6002_PASSWORD=${override_6002:-${SIP_6002_PASSWORD:-$(openssl rand -hex 16)}
SIP_6003_PASSWORD=${override_6003:-${SIP_6003_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6101_PASSWORD=${override_6101:-${SIP_6101_PASSWORD:-$(openssl rand -hex 16)}}
SIP_6102_PASSWORD=${override_6102:-${SIP_6102_PASSWORD:-$(openssl rand -hex 16)}}
+SIP_6103_PASSWORD=${override_6103:-${SIP_6103_PASSWORD:-$(openssl rand -hex 16)}}
SIP_EXTERNAL_MEDIA_ADDRESS=${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
SIP_RTP_START=${SIP_RTP_START:-10000}
SIP_RTP_END=${SIP_RTP_END:-10099}
@@ -36,12 +38,13 @@ SIP_6002_PASSWORD=$SIP_6002_PASSWORD
SIP_6003_PASSWORD=$SIP_6003_PASSWORD
SIP_6101_PASSWORD=$SIP_6101_PASSWORD
SIP_6102_PASSWORD=$SIP_6102_PASSWORD
+SIP_6103_PASSWORD=$SIP_6103_PASSWORD
EOF
chmod 600 "$credentials"
-export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
+export SIP_6001_PASSWORD SIP_6002_PASSWORD SIP_6003_PASSWORD SIP_6101_PASSWORD SIP_6102_PASSWORD SIP_6103_PASSWORD SIP_EXTERNAL_MEDIA_ADDRESS
export SIP_RTP_START SIP_RTP_END
-envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
+envsubst '${SIP_6001_PASSWORD} ${SIP_6002_PASSWORD} ${SIP_6003_PASSWORD} ${SIP_6101_PASSWORD} ${SIP_6102_PASSWORD} ${SIP_6103_PASSWORD} ${SIP_EXTERNAL_MEDIA_ADDRESS}' \
< /opt/lct-sip/pjsip.conf.template > /etc/asterisk/pjsip.conf
chmod 640 /etc/asterisk/pjsip.conf
chown root:asterisk /etc/asterisk/pjsip.conf
diff --git a/sip/extensions.conf b/sip/extensions.conf
index 1d68ff5..0c2373c 100644
--- a/sip/extensions.conf
+++ b/sip/extensions.conf
@@ -12,9 +12,10 @@ exten => _600[1-3],1,NoOp(LCT training call ${CALLERID(num)} -> ${EXTEN})
same => n,StopMixMonitor()
same => n,Hangup()
-; 6101/6102 — браузерные WebRTC-абоненты. Тот же учебный контекст и запись,
-; но отдельные endpoints не ломают обычные UDP/TCP-софтфоны 6001–6003.
-exten => _610[1-2],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
+; 6101–6103 — браузерные WebRTC-абоненты: ДДС, старший группы, вышестоящий
+; начальник. Тот же учебный контекст и запись, но отдельные endpoints не ломают
+; обычные UDP/TCP-софтфоны 6001–6003.
+exten => _610[1-3],1,NoOp(LCT WebRTC training call ${CALLERID(num)} -> ${EXTEN})
same => n,Set(RECORDING_FILE=${STRFTIME(${EPOCH},UTC,%Y%m%d-%H%M%S)}-${CALLERID(num)}-${EXTEN}-${UNIQUEID})
same => n,MixMonitor(/recordings/${RECORDING_FILE}.wav,b)
same => n,Dial(PJSIP/${EXTEN},30)
diff --git a/sip/pjsip.conf.template b/sip/pjsip.conf.template
index b75f5f0..6bd35df 100644
--- a/sip/pjsip.conf.template
+++ b/sip/pjsip.conf.template
@@ -157,3 +157,22 @@ type=aor
max_contacts=2
remove_existing=yes
qualify_frequency=0
+
+; Вышестоящий начальник ДДС: роль играет преподаватель со страницы
+; /phone?role=superior. Обязательный голосовой маршрут по ответу заказчика (П.7).
+[6103](webrtc-endpoint)
+auth=6103-auth
+aors=6103
+callerid=Веб вышестоящий начальник <6103>
+
+[6103-auth]
+type=auth
+auth_type=userpass
+username=6103
+password=${SIP_6103_PASSWORD}
+
+[6103]
+type=aor
+max_contacts=2
+remove_existing=yes
+qualify_frequency=0
From 94886b69144e27dfc02d2adf943e46efbc87032d Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 23:44:02 +0300
Subject: [PATCH 41/41] =?UTF-8?q?lct-53:=20=D0=94=D0=94=D0=A1=20=D1=81?=
=?UTF-8?q?=D0=BE=D0=BE=D0=B1=D1=89=D0=B0=D0=B5=D1=82=20=D0=B2=20112=20?=
=?UTF-8?q?=D0=BE=D0=B1=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5=20=D0=BA?=
=?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D0=BE=20?=
=?UTF-8?q?=D0=B4=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4=D1=83=20=D1=81=20=D0=BC?=
=?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=C2=AB?=
=?UTF-8?q?=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5?=
=?UTF-8?q?=D1=82=C2=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/ws/station.py | 6 +++---
backend/app/session/dds.py | 7 ++++++-
backend/tests/test_dds_desk_apply.py | 3 ++-
backend/tests/test_station.py | 2 +-
frontend/src/pages/dds/Dds.tsx | 2 +-
5 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py
index 596341c..e3e52a9 100644
--- a/backend/app/api/ws/station.py
+++ b/backend/app/api/ws/station.py
@@ -3,9 +3,9 @@
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
локального прототипа не нужна внешняя телефония или аудиомодель.
-Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
-этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
-в отчёте и становится сорванным выездом с конкретной причиной.
+`card.bounce` сервер отклоняет: ДДС не возвращает карточку в 112 при принятии
+и не правит её поля. Об ошибке, которую вскрыл доклад бригады с места,
+диспетчер сообщает в 112 по телефону (ответ заказчика, П.3).
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
Отсев повторов и операция хранилища — в `run_command`.
diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py
index 48fc334..d5f09e5 100644
--- a/backend/app/session/dds.py
+++ b/backend/app/session/dds.py
@@ -591,8 +591,13 @@ class DdsDesk(BaseModel):
self.finish_work()
outcome.finished = True
case "card.bounce":
+ # Возврата карточки на этапе принятия нет; об ошибке, которую
+ # вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
+ # (ответ заказчика, П.3).
return _refused(
- "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
+ "Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
+ "Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
+ "в 112 по телефону"
)
case "zone.decision":
previous = next(
diff --git a/backend/tests/test_dds_desk_apply.py b/backend/tests/test_dds_desk_apply.py
index fa6c561..86d7d0c 100644
--- a/backend/tests/test_dds_desk_apply.py
+++ b/backend/tests/test_dds_desk_apply.py
@@ -175,7 +175,8 @@ def test_bounce_is_refused_in_favour_of_112_control():
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
assert outcome.error == (
- "ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112")
+ "Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
+ "Если ошибка вскрылась по докладу бригады с места — сообщите о ней в 112 по телефону")
def test_refusals_leave_desk_untouched():
diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py
index e029f76..d367941 100644
--- a/backend/tests/test_station.py
+++ b/backend/tests/test_station.py
@@ -123,7 +123,7 @@ def test_dds_cannot_return_card_for_112_quality_control(client):
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
error = read_until(station, "error")
- assert "служба контроля 112" in error["message"]
+ assert "сообщите о ней в 112 по телефону" in error["message"]
assert state.bounced_fields == []
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 59267b6..89f1f7d 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -669,7 +669,7 @@ export function Dds() {
onClick={() => { channel.current?.send({ type: "zone.decision", in_zone: true }); }}>Наша
{ channel.current?.send({ type: "zone.decision", in_zone: false }); }}>Не наша
- ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.
+ Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 112 по телефону.
{
if (reply.dirty && !send({ type: "card.reply", card_id: cardId, text: reply.text })) return;