lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный руками, он разошёлся бы с фактами при первой же правке сценария, и курсанта оштрафовали бы за правильный ответ. Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый недобытый факт с эталонным вопросом, расхождение самооценки — что курсант заметил сам, чего не заметил, что отметил зря. Не заметил — самое ценное для разбора. Внешний монитор — не отдельное приложение, а другой режим отрисовки тех же событий: крупный таймер опроса, ход разговора, карточка, после оценки разбор на весь экран. Коррекция преподавателем сохраняет автооценку рядом: видно, что скорректировано и кем. Найдено: все метрики весили одинаково, и курсант, не задавший ни одного вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные веса (полнота опроса — 4) дают 74; окончательные утверждает методист, вопрос записан в DEBRIEF.md.
This commit is contained in:
parent
25143645d3
commit
fadfa3e479
20 changed files with 781 additions and 9 deletions
48
backend/tests/test_reference.py
Normal file
48
backend/tests/test_reference.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Эталонный диалог собирается из фактов, а не пишется руками."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scenarios.loader import load_file
|
||||
from app.scoring.reference import build, missed_steps
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scenario():
|
||||
return load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
|
||||
|
||||
|
||||
def test_every_checklist_question_becomes_a_step(scenario):
|
||||
reference = build(scenario)
|
||||
assert [step.checklist_id for step in reference.steps] == [
|
||||
item.id for item in scenario.checklist if item.question
|
||||
]
|
||||
assert reference.first_line == scenario.first_line
|
||||
|
||||
|
||||
def test_answers_come_from_facts_not_from_prose(scenario):
|
||||
"""Эталон не может разойтись с фактами: ответы взяты из них же."""
|
||||
reference = build(scenario)
|
||||
address = next(step for step in reference.steps if step.fact_id == "f_address")
|
||||
fact = next(fact for fact in scenario.facts if fact.id == "f_address")
|
||||
assert fact.value in address.answer
|
||||
|
||||
|
||||
def test_step_without_a_fact_has_no_answer(scenario):
|
||||
"""«Представьтесь» факта не добывает — и ответа в эталоне у него нет."""
|
||||
caller = next(step for step in build(scenario).steps if step.checklist_id == "q_caller")
|
||||
assert caller.fact_id is None and caller.answer is None
|
||||
assert not caller.required
|
||||
|
||||
|
||||
def test_missed_steps_are_only_the_required_ones(scenario):
|
||||
missed = missed_steps(scenario, revealed=["f_address"])
|
||||
assert [step.fact_id for step in missed] == ["f_what_burns", "f_people", "f_smoke", "f_gas"]
|
||||
assert all(step.question for step in missed), "в разборе нужен текст эталонного вопроса"
|
||||
|
||||
|
||||
def test_without_slot_machine_nothing_is_declared_missed(scenario):
|
||||
assert missed_steps(scenario, revealed=None) == []
|
||||
|
|
@ -258,3 +258,52 @@ def test_checklist_is_closed_until_the_call_is_over(client):
|
|||
after = client.get(f"/api/sessions/{session_id}/checklist")
|
||||
assert after.status_code == 200
|
||||
assert all(item["question"] for item in after.json())
|
||||
|
||||
|
||||
def test_report_shows_missed_questions_and_self_assessment_gap(client):
|
||||
"""Главное в разборе: по пропущенному пункту виден эталонный вопрос,
|
||||
а расхождение самооценки — отдельным блоком."""
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "hint.request"})
|
||||
trainee.send_json({"type": "kio.patch", "fields": {"address": "улица Ленина, 14", "dds": "01"}})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: hub.get(session_id).score is not None)
|
||||
# Курсант считает, что пропустил только адрес — а не добыл он всё.
|
||||
trainee.send_json({"type": "self_assessment.submit", "missed": ["q_address"], "comment": "торопился"})
|
||||
wait_for(lambda: hub.get(session_id).self_assessed)
|
||||
|
||||
response = client.get(f"/api/sessions/{session_id}/report")
|
||||
assert response.status_code == 200, response.text
|
||||
report = response.json()
|
||||
|
||||
assert report["missed_checklist"], "не добытые пункты должны быть перечислены"
|
||||
questions = {item["checklist_id"]: item["question"] for item in report["reference_questions"]}
|
||||
assert all(questions.get(item) for item in report["missed_checklist"]), "у пропущенного нет эталонного вопроса"
|
||||
|
||||
for finding in report["findings"]:
|
||||
assert finding["code"] and finding["fact"], "отметка без обоснования"
|
||||
|
||||
diff = report["self_assessment_diff"]
|
||||
assert "q_address" in diff["overcautious"] or "q_address" in diff["noticed"]
|
||||
assert diff["unnoticed"], "курсант не заметил часть пропущенного — это и есть материал разбора"
|
||||
assert report["hints_used"], "использованные подсказки попадают в разбор"
|
||||
|
||||
|
||||
def test_instructor_correction_keeps_the_automatic_score(client):
|
||||
with lesson(client) as (session_id, _):
|
||||
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
|
||||
trainee.send_json({"type": "call.answer"})
|
||||
trainee.send_json({"type": "call.hangup"})
|
||||
wait_for(lambda: hub.get(session_id).score is not None)
|
||||
|
||||
auto = client.get(f"/api/sessions/{session_id}/report").json()["score_auto"]
|
||||
corrected = client.patch(
|
||||
f"/api/sessions/{session_id}/report",
|
||||
json={"score_final": 80.0, "comment": "связь рвалась не по вине курсанта"},
|
||||
).json()
|
||||
|
||||
assert corrected["score_final"] == 80.0
|
||||
assert corrected["score_auto"] == auto, "автооценка должна сохраниться рядом"
|
||||
assert corrected["overridden_by"] == "преподаватель"
|
||||
|
|
|
|||
Loading…
Reference in a new issue