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 ( +
+

Разбор по отметкам

+

+ Снятая отметка не штрафует свою метрику, добавленная — штрафует. Балл пересчитывается + по тем же критериям и весам; автоматическая оценка ({report.score_auto.toFixed(1)}) сохраняется рядом. + {report.score_reviewed != null && ` После разбора: ${report.score_reviewed.toFixed(1)}.`} +

+ + + {report.findings.map((finding, index) => ( + + + + + ))} + {report.findings.length === 0 && } + +
{finding.code} + {finding.summary} +
{finding.fact}{finding.norm && ` · ${finding.norm}`}
+ {finding.review &&
+ {finding.review.decision === "dismissed" ? "Снята" : "Подтверждена"}: {finding.review.reason} +
} +
+ setReasons((current) => ({ ...current, [index]: event.target.value }))} /> + + +
+
Автоматических отметок нет.
+ +

Добавить отметку

+
+ + {cards.length > 0 && } + + + +
+ {error &&

{error}

} +
+ ); +} 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") && }
+

Паузы занятия: {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 && } {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.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() {

{" "}

-

Паузы занятия: {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} +
)}
Добавить отметку
- {cards.length > 0 && } } + {phone.status === "registered" && contacts.map((contact) => )} {phone.status === "incoming" && } {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() {
-

ДДС не исправляет и не возвращает заполненную карточку: её качество контролируют оператор и служба контроля 112.

+

Карточку не правят на АРМ ДДС и не возвращают в 112 при принятии. Если бригада с места доложит о расхождении с карточкой — сообщите об ошибке в 112 по телефону.