lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""Сессии: создание, состояние, история.
|
|
|
|
|
|
|
|
|
|
|
|
`group_id` и `mode` принимаются с первого дня — размечать накопленные сессии
|
|
|
|
|
|
задним числом не надо (docs/arch/CONTRACT.md#http-api).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
|
|
from app.db import repo
|
|
|
|
|
|
from app.db.base import get_session
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
from app.domain.events import SessionMode, SessionReport
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.scenarios import store
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
from app.scoring.report import build as build_report
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.session.hub import hub
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionCreate(BaseModel):
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
mode: SessionMode
|
|
|
|
|
|
trainee: str | None = None
|
|
|
|
|
|
group: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SessionOut(BaseModel):
|
|
|
|
|
|
session_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
mode: SessionMode
|
|
|
|
|
|
attempt: int
|
|
|
|
|
|
trainee_id: UUID | None = None
|
|
|
|
|
|
group_id: UUID | None = None
|
|
|
|
|
|
started_at: datetime | None = None
|
|
|
|
|
|
ended_at: datetime | None = None
|
|
|
|
|
|
end_reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _out(session) -> SessionOut:
|
|
|
|
|
|
return SessionOut(
|
|
|
|
|
|
session_id=session.id,
|
|
|
|
|
|
scenario_id=session.scenario_id,
|
|
|
|
|
|
mode=session.mode,
|
|
|
|
|
|
attempt=session.attempt,
|
|
|
|
|
|
trainee_id=session.trainee_id,
|
|
|
|
|
|
group_id=session.group_id,
|
|
|
|
|
|
started_at=session.started_at,
|
|
|
|
|
|
ended_at=session.ended_at,
|
|
|
|
|
|
end_reason=session.end_reason,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=SessionOut, status_code=201)
|
|
|
|
|
|
async def create(body: SessionCreate, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
|
|
|
|
|
group = await repo.ensure_group(db, body.group) if body.group else None
|
|
|
|
|
|
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
|
|
|
|
|
session = await repo.create_session(
|
|
|
|
|
|
db,
|
|
|
|
|
|
scenario_id=body.scenario_id,
|
|
|
|
|
|
mode=body.mode.value,
|
|
|
|
|
|
trainee_id=trainee.id if trainee else None,
|
|
|
|
|
|
group_id=group.id if group else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
return _out(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{session_id}", response_model=SessionOut)
|
|
|
|
|
|
async def read(session_id: UUID, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
|
|
|
|
|
session = await repo.get_session(db, session_id)
|
|
|
|
|
|
if session is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
return _out(session)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
class ChecklistItemOut(BaseModel):
|
|
|
|
|
|
id: str
|
|
|
|
|
|
question: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{session_id}/checklist", response_model=list[ChecklistItemOut])
|
|
|
|
|
|
async def checklist(session_id: UUID) -> list[ChecklistItemOut]:
|
|
|
|
|
|
"""Чек-лист для самооценки — **только после конца звонка**.
|
|
|
|
|
|
|
|
|
|
|
|
Во время звонка это содержимое подсказок: отдать его значит выдать
|
|
|
|
|
|
в контрольном режиме то, чего там быть не должно. После звонка курсант
|
|
|
|
|
|
по нему отмечает, что, по его мнению, пропустил.
|
|
|
|
|
|
"""
|
|
|
|
|
|
state = hub.get(session_id)
|
|
|
|
|
|
if state is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="session_not_found")
|
|
|
|
|
|
if not state.ended:
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="call_not_ended")
|
|
|
|
|
|
scenario = store.get(state.scenario_id)
|
|
|
|
|
|
if scenario is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
|
|
|
|
|
return [
|
|
|
|
|
|
ChecklistItemOut(id=item.id, question=item.question)
|
|
|
|
|
|
for item in scenario.checklist
|
|
|
|
|
|
if item.question
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-16 и половина lct-19: разбор, отчёт, внешний монитор, эталон
Эталонный диалог собирается кодом из фактов и чек-листа: написанный
руками, он разошёлся бы с фактами при первой же правке сценария,
и курсанта оштрафовали бы за правильный ответ.
Отчёт: метрики фактом против норматива со ссылкой, отметка E1 на каждый
недобытый факт с эталонным вопросом, расхождение самооценки — что
курсант заметил сам, чего не заметил, что отметил зря. Не заметил —
самое ценное для разбора.
Внешний монитор — не отдельное приложение, а другой режим отрисовки тех
же событий: крупный таймер опроса, ход разговора, карточка, после оценки
разбор на весь экран.
Коррекция преподавателем сохраняет автооценку рядом: видно, что
скорректировано и кем.
Найдено: все метрики весили одинаково, и курсант, не задавший ни одного
вопроса, но заполнивший карточку руками, получал 87 из 100. Предварительные
веса (полнота опроса — 4) дают 74; окончательные утверждает методист,
вопрос записан в DEBRIEF.md.
2026-09-17 21:32:12 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@router.get("", response_model=list[SessionOut])
|
|
|
|
|
|
async def listing(
|
|
|
|
|
|
trainee: UUID | None = None,
|
|
|
|
|
|
group: UUID | None = None,
|
|
|
|
|
|
mode: SessionMode | None = None,
|
|
|
|
|
|
since: datetime | None = Query(default=None, alias="from"),
|
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
|
db: AsyncSession = Depends(get_session),
|
|
|
|
|
|
) -> list[SessionOut]:
|
|
|
|
|
|
rows = await repo.history(
|
|
|
|
|
|
db,
|
|
|
|
|
|
trainee_id=trainee,
|
|
|
|
|
|
group_id=group,
|
|
|
|
|
|
mode=mode.value if mode else None,
|
|
|
|
|
|
since=since,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
)
|
|
|
|
|
|
return [_out(row) for row in rows]
|