lct-hack/backend/app/api/http/sessions.py

171 lines
6.1 KiB
Python
Raw Normal View History

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
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
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)
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]