fix: отчёт и чек-лист при передаче 112→ДДС по сценарию оператора
This commit is contained in:
parent
491655de5c
commit
1d6b4d67da
3 changed files with 109 additions and 7 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue