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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
# До них директива копится в состоянии и видна в разборе.
|
||||
|
|
|
|||
Loading…
Reference in a new issue