lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон

Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.

Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.

Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.

Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.

Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:32:12 +03:00
commit fadfa3e479
20 changed files with 781 additions and 9 deletions

View file

@ -13,8 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db import repo
from app.db.base import get_session
from app.domain.events import SessionMode
from app.domain.events import SessionMode, SessionReport
from app.scenarios import store
from app.scoring.report import build as build_report
from app.session.hub import hub
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@ -103,6 +104,53 @@ async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
]
class ScoreOverride(BaseModel):
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
score_final: float
comment: str = ""
author: str = "преподаватель"
def _live(session_id: UUID):
state = hub.get(session_id)
if state is None:
raise HTTPException(status_code=404, detail="session_not_found")
scenario = store.get(state.scenario_id)
if scenario is None:
raise HTTPException(status_code=404, detail="scenario_not_found")
return state, scenario
@router.get("/{session_id}/report", response_model=SessionReport)
async def report(session_id: UUID) -> SessionReport:
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
Оценка курсанту открывается событием `score.ready` после самооценки; здесь
прав нет — в прототипе нет входа, и точка доступна всем, у кого есть номер
занятия (docs/arch/CONTRACT.md).
"""
state, scenario = _live(session_id)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
return build_report(session_id, state, scenario)
@router.patch("/{session_id}/report", response_model=SessionReport)
async def override(session_id: UUID, body: ScoreOverride) -> SessionReport:
"""Тренажёр готовит материал, преподаватель имеет последнее слово."""
state, scenario = _live(session_id)
if state.score is None:
raise HTTPException(status_code=409, detail="score_not_ready")
state.score = {
**state.score,
"score_final": body.score_final,
"overridden_by": body.author,
"override_comment": body.comment,
}
return build_report(session_id, state, scenario)
@router.get("", response_model=list[SessionOut])
async def listing(
trainee: UUID | None = None,

View file

@ -111,6 +111,7 @@ async def _handle(session_id: UUID, state, event) -> None:
return
checklist_id, question = nxt
state.hints_shown.append(checklist_id)
state.hints_log.append((checklist_id, now_utc()))
shown = HintShown(checklist_id=checklist_id, question=question)
hub.broadcast(session_id, shown)
if hub.journal:

View file

@ -16,6 +16,7 @@ from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
CallEndReason,
ScoreReady,
CallIncoming,
ErrorEvent,
ErrorKind,
@ -131,6 +132,14 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
case "session.stop":
await _stop(session_id)
case "instructor_note.add":
state = hub.get(session_id)
if state is not None:
state.notes.append({
"type": "instructor_note.shown",
"transcript_ref": event.transcript_ref,
"text": event.text,
"author": "преподаватель",
})
hub.to_observers(
session_id,
InstructorNoteShown(
@ -147,6 +156,17 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
state = hub.get(session_id)
if state is not None:
hub.to_observers(session_id, ReferenceStarted(scenario_id=state.scenario_id))
case "score.override":
state = hub.get(session_id)
if state is not None and state.score is not None:
# Автооценка остаётся рядом: видно, что скорректировано и кем.
state.score = {
**state.score,
"score_final": float(event.verdict) if event.verdict.replace(".", "", 1).isdigit() else state.score["score_auto"],
"overridden_by": "преподаватель",
"override_comment": event.comment,
}
hub.to_observers(session_id, ScoreReady(session_id=session_id))
case "director.inject":
# Поведение звонящего — карточка lct-07, пульт — lct-22.
# До них директива копится в состоянии и видна в разборе.