diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index fa70b0e..74a7f5d 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -25,10 +25,10 @@ from app.domain.events import Exercise, SessionMode, SessionReport from app.domain.roles import Role from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current from app.domain.timers import TimerCode -from app.scenarios import store from app.scoring.export import to_csv, to_pdf from app.scoring.report import build as build_report from app.session.checkpoint import load_state +from app.session.finish import scoring_scenario from app.session.hub import hub from app.voice.recording import recording_path @@ -361,7 +361,7 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut raise HTTPException(status_code=404, detail="session_not_found") if not state.ended: raise HTTPException(status_code=409, detail="call_not_ended") - scenario = state.scenario or store.get(state.scenario_id) + scenario = scoring_scenario(state) if scenario is None: raise HTTPException(status_code=404, detail="scenario_not_found") return [ @@ -390,7 +390,7 @@ def _live(session_id: UUID): state = hub.get(session_id) if state is None: raise HTTPException(status_code=404, detail="session_not_found") - scenario = state.scenario or store.get(state.scenario_id) + scenario = scoring_scenario(state) if scenario is None: raise HTTPException(status_code=404, detail="scenario_not_found") return state, scenario @@ -559,7 +559,7 @@ async def override( raise HTTPException(status_code=404, detail="session_not_found") state = hub.get(session_id) live_ready = state is not None and state.score is not None - scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None + scenario = scoring_scenario(state) if live_ready else None if live_ready and scenario is None: raise HTTPException(status_code=409, detail="scenario_not_found") score = await db.scalar(select(Score).where(Score.session_id == session_id)) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index f0f484c..da03aa5 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -138,13 +138,24 @@ def score_current_dds(state) -> DdsCardRecord: ) +def scoring_scenario(state): + """Сценарий, по которому оценивается и разбирается занятие. + + После передачи 112→ДДС `state.scenario` — активная карточка очереди, + а КИО курсанта оценивается по эталону упражнения 112. Балл, разбор + и чек-лист самооценки обязаны брать один и тот же сценарий. + """ + if state.handoff_to_dds and state.operator_scenario: + return state.operator_scenario + return state.scenario or store.get(state.scenario_id) + + async def finish(session_id: UUID, state) -> None: if state.recorder is not None: path = await asyncio.to_thread(state.recorder.finalize) state.recording_path = str(path) if path else None # Сценарий занятия, а не библиотечный: директивы могли поправить эталон. - scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario - else state.scenario or store.get(state.scenario_id)) + scenario = scoring_scenario(state) if scenario is None: return @@ -346,7 +357,7 @@ async def refresh_archived_report(session_id: UUID, state) -> None: """Дополнить сохранённый разбор самооценкой, пришедшей после hangup.""" if state.score is None: return - scenario = state.scenario or store.get(state.scenario_id) + scenario = scoring_scenario(state) if scenario is None: return state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json") diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py index 9e781a3..3e2e52a 100644 --- a/backend/tests/test_card_exercise.py +++ b/backend/tests/test_card_exercise.py @@ -397,3 +397,94 @@ def test_model_coaching_reaches_report_without_changing_score(client, monkeypatc assert score["score_auto"] < 100 finally: control.__exit__(None, None, None) + + +def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypatch): + """Балл, разбор, чек-лист и коррекция — по сценарию 112, а не по активной карточке ДДС.""" + import asyncio + + from app.api.auth import Principal + from app.domain.roles import Role + from app.session.finish import refresh_archived_report + + session_id, control = start( + client, handoff_to_dds=True, + scenario_ids=["fire-apartment-l2", "t01-1-fire-container"], + ) + try: + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + read_until(trainee, "card.briefing") + trainee.send_json({"type": "kio.patch", "fields": { + "address": "улица Ленина, 14", "incident_type": "fire", + "victims_count": 2, "description": "горит балкон", + "signs": ["жилой дом", "балкон", "открытое пламя"], + }}) + wait_for(lambda: hub.get(session_id).kio.incident_code) + trainee.send_json({"type": "card.submit"}) + read_until(trainee, "call.ended") + + with client.websocket_connect(f"/ws/station/{session_id}") as station: + read_until(station, "card.received") + snapshot = read_until(station, "station.state")["snapshot"] + prepared = next(item for item in snapshot["queue_cards"] + if item["scenario_id"] == "t01-1-fire-container") + station.send_json({"type": "card.open", "card_id": prepared["card_id"]}) + read_until(station, "card.received") + station.send_json({"type": "station.finish"}) + read_until(station, "score.ready") + + wait_for(lambda: hub.get(session_id).score) + state = hub.get(session_id) + # Предусловие ошибки: к завершению активна не карточка курсанта. + assert state.scenario_id == "t01-1-fire-container" + operator = store.get("fire-apartment-l2") + + checklist = client.get(f"/api/sessions/{session_id}/checklist").json() + assert [item["id"] for item in checklist] == [ + item.id for item in operator.checklist if item.question + ] + + report = client.get(f"/api/sessions/{session_id}/report").json() + assert report["scenario_id"] == "fire-apartment-l2" + assert report["metrics"] == state.score["metrics"] + + state.self_assessed = True + state.self_assessment = {"missed": [checklist[0]["id"]], "comment": ""} + asyncio.run(refresh_archived_report(session_id, state)) + assert state.score["full_report"]["scenario_id"] == "fire-apartment-l2" + assert state.score["full_report"]["metrics"] == state.score["metrics"] + + session_row = SimpleNamespace(id=session_id, owner_login="teacher") + score_row = SimpleNamespace( + score_auto=state.score["score_auto"], score_final=state.score["score_auto"], + overridden_by=None, override_comment=None, + report={"full_report": state.score["full_report"]}, + ) + + class FakeDb: + async def scalar(self, statement): + return score_row + + def add(self, row): + pass + + async def commit(self): + pass + + async def fake_session(db, key): + return session_row + + monkeypatch.setattr(sessions_http.repo, "get_session", fake_session) + monkeypatch.setattr( + sessions_http, "require", + lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", + role=Role.INSTRUCTOR), + ) + corrected = asyncio.run(sessions_http.override( + session_id, + sessions_http.ScoreOverride(score_final=50, comment="проверено"), + object(), FakeDb(), + )) + assert corrected.scenario_id == "fire-apartment-l2" + finally: + control.__exit__(None, None, None)