fix: отчёт и чек-лист при передаче 112→ДДС по сценарию оператора

This commit is contained in:
gglamer 2026-09-26 19:02:24 +00:00
commit 1d6b4d67da
3 changed files with 109 additions and 7 deletions

View file

@ -25,10 +25,10 @@ from app.domain.events import Exercise, SessionMode, SessionReport
from app.domain.roles import Role from app.domain.roles import Role
from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current from app.domain.statuses import SERVICE_STATUS_LABELS, StationSnapshot, current
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.export import to_csv, to_pdf from app.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report from app.scoring.report import build as build_report
from app.session.checkpoint import load_state from app.session.checkpoint import load_state
from app.session.finish import scoring_scenario
from app.session.hub import hub from app.session.hub import hub
from app.voice.recording import recording_path 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") raise HTTPException(status_code=404, detail="session_not_found")
if not state.ended: if not state.ended:
raise HTTPException(status_code=409, detail="call_not_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: if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found") raise HTTPException(status_code=404, detail="scenario_not_found")
return [ return [
@ -390,7 +390,7 @@ def _live(session_id: UUID):
state = hub.get(session_id) state = hub.get(session_id)
if state is None: if state is None:
raise HTTPException(status_code=404, detail="session_not_found") 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: if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found") raise HTTPException(status_code=404, detail="scenario_not_found")
return state, scenario return state, scenario
@ -559,7 +559,7 @@ async def override(
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
state = hub.get(session_id) state = hub.get(session_id)
live_ready = state is not None and state.score is not None 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: if live_ready and scenario is None:
raise HTTPException(status_code=409, detail="scenario_not_found") raise HTTPException(status_code=409, detail="scenario_not_found")
score = await db.scalar(select(Score).where(Score.session_id == session_id)) score = await db.scalar(select(Score).where(Score.session_id == session_id))

View file

@ -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: async def finish(session_id: UUID, state) -> None:
if state.recorder is not None: if state.recorder is not None:
path = await asyncio.to_thread(state.recorder.finalize) path = await asyncio.to_thread(state.recorder.finalize)
state.recording_path = str(path) if path else None state.recording_path = str(path) if path else None
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон. # Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario scenario = scoring_scenario(state)
else state.scenario or store.get(state.scenario_id))
if scenario is None: if scenario is None:
return return
@ -346,7 +357,7 @@ async def refresh_archived_report(session_id: UUID, state) -> None:
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup.""" """Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
if state.score is None: if state.score is None:
return return
scenario = state.scenario or store.get(state.scenario_id) scenario = scoring_scenario(state)
if scenario is None: if scenario is None:
return return
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json") state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")

View file

@ -397,3 +397,94 @@ def test_model_coaching_reaches_report_without_changing_score(client, monkeypatc
assert score["score_auto"] < 100 assert score["score_auto"] < 100
finally: finally:
control.__exit__(None, None, None) 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)