lct-hack/backend/app/session/finish.py

72 lines
3.4 KiB
Python
Raw Normal View History

"""Завершение занятия: посчитать оценку и положить её в журнал.
Детерминированный слой считается сразу по завершении звонка (lct-12).
Курсанту `score.ready` уходит **только после самооценки**: сначала он сверяет
своё ощущение с объективной картиной, и расхождение — отдельный материал
для преподавателя (docs/product/DEBRIEF.md). Преподаватель и монитор получают
событие сразу: им ждать нечего.
"""
import logging
from uuid import UUID
from app.domain.events import ScoreReady
from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.competency import radar
from app.scoring.gost import evaluate
from app.session.hub import hub
log = logging.getLogger(__name__)
async def finish(session_id: UUID, state) -> None:
lct-22: пульт директив — преподаватель ведёт ситуацию Мягкие директивы перекрывают дугу сценария и применяются со следующей реплики: разговор не дёргается от того, что преподаватель что-то нажал. Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что перебивание, и запускает норматив обратного дозвона; второй пострадавший правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан переспросить. Своя копия сценария на занятие: директивы правят факты и эталон, а сценарий был общим на библиотеку — правка в одной группе протекла бы во все остальные. Тест проверяет, что библиотека не изменилась. Свободный текст честно отказывает: офлайн-дерево предгенерировано, произвольную фразу взять неоткуда, и преподаватель видит это на пульте. Ни одна директива не трогает карточку курсанта — он управляет ситуацией, а не работой обучаемого. Занятие теперь собирается целиком и только потом регистрируется: наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
return
result = evaluate(
scenario=scenario,
kio=state.kio,
timers=state.timers,
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
feat: уточнение адреса — место происшествия не равно адресу заявителя (lct-35) Главный предмет проверки в билетах заказчика — адрес: названный заявителем часто неверен, а настоящий добывается переспросом («ул. Станционная, 28» оказывается Королёвом). Памятка АРМ-112 называет это обычным делом. До этой правки оценка работала наоборот: ground_truth.address сравнивался с единственным значением факта, поэтому курсант, правильно переспросивший и записавший настоящий адрес, получал расхождение с эталоном, а записавший ориентир — зачёт. - Схема: у факта появились refined и refine_on, задаются только вместе. - Слот-автомат различает уточнение и повтор: повтор раздражает звонящего, уточнение — нет, оператор спросил о другом и получил другое. - Звонящий поправляется отдельной репликой, а не повторяет прежнее значение; офлайн-таблица получила секцию refine. - Оценка: метрика address_refined (E1, опрос). Сделана отдельной, а не правкой метрики address: «записал не тот адрес» и «не спросил» — разные навыки и разные компетенции в радаре. - Общие пункты чек-листа подключаются по требованию сценария: формулировки в checklists/common.yaml, сценарий объявляет пункт с тем же id без текста. Добавлять «уточните адрес» во все сценарии нельзя — неотработанный пункт штрафует за вопрос, которого сценарий не требовал. - scenarios/fire-private-house-l3.yaml — первый сценарий из библиотеки заказчика (билет 3, вызов 1). Риск механики проверен на живой модели: «Назовите адрес» и «Это точно Москва?» эмбеддинги не путают, тест это фиксирует. 125 тестов зелёных (12 новых), make typecheck чистый.
2026-09-19 20:26:49 +03:00
refined_facts=list(state.slots.refined) if state.slots else None,
end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
)
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
required = scenario.ground_truth.required_facts
revealed = [fact.id for fact in state.slots.revealed_facts()] if state.slots else []
codes: dict[str, int] = {}
for finding in result.findings:
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
state.score = {
"score_auto": result.score,
"summary": {
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
"facts_got": len([fact for fact in required if fact in revealed]),
"facts_required": len(required),
"hints": len(state.hints_shown),
"codes": codes,
},
"metrics": [metric.model_dump() for metric in result.metrics],
"findings": [finding.model_dump(mode="json") for finding in result.findings],
"competencies": [item.model_dump() for item in radar(result.metrics)],
"unavailable": result.unavailable,
}
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal:
await hub.journal.score(session_id, result.score, state.score)
hub.to_observers(session_id, ScoreReady(session_id=session_id))
await release_score(session_id, state)
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and state.self_assessed:
hub.to_trainee(session_id, ScoreReady(session_id=session_id))