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,