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

123 lines
4 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
from app.domain.events import SessionMode
from app.scenarios import store
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-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]