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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
This commit is contained in:
parent
59d5a6a26d
commit
6b2c96bab6
22 changed files with 1380 additions and 8 deletions
0
backend/app/api/http/__init__.py
Normal file
0
backend/app/api/http/__init__.py
Normal file
47
backend/app/api/http/scenarios.py
Normal file
47
backend/app/api/http/scenarios.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Библиотека сценариев по HTTP.
|
||||
|
||||
`GET /api/scenarios/{id}` **не отдаёт** `facts` и `ground_truth`: иначе курсант
|
||||
откроет DevTools и прочитает адрес до того, как его спросит.
|
||||
|
||||
`checklist` скрыт по той же причине и даже более веской: чек-лист — это
|
||||
содержимое подсказок. Отдать его целиком значит выдать в контрольном режиме
|
||||
то, чего там не должно быть вовсе, и обойти выдачу по одному пункту
|
||||
(docs/product/MODES.md#подсказка-по-запросу). Подсказки идут только событием
|
||||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.scenarios import store
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def listing() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"id": scenario.id,
|
||||
"title": scenario.title,
|
||||
"type": scenario.type.value,
|
||||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
"modes": scenario.modes,
|
||||
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
|
||||
}
|
||||
for scenario in store.all_scenarios()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{scenario_id}")
|
||||
async def read(scenario_id: str) -> dict:
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
payload = scenario.model_dump(mode="json")
|
||||
for key in HIDDEN_FROM_TRAINEE:
|
||||
payload.pop(key, None)
|
||||
payload["required_fields"] = scenario.required_fields
|
||||
return payload
|
||||
93
backend/app/api/http/sessions.py
Normal file
93
backend/app/api/http/sessions.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Сессии: создание, состояние, история.
|
||||
|
||||
`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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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]
|
||||
Loading…
Reference in a new issue