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/scenarios/__init__.py
Normal file
0
backend/app/scenarios/__init__.py
Normal file
124
backend/app/scenarios/loader.py
Normal file
124
backend/app/scenarios/loader.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Загрузчик библиотеки сценариев.
|
||||
|
||||
Проверяет **все** YAML на старте приложения и падает с внятным сообщением
|
||||
при первом же нарушении: сломанный сценарий, найденный посреди занятия, —
|
||||
сценарий, которого не должно случиться (docs/arch/BACKEND.md).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.classifiers import DDS_BY_INCIDENT
|
||||
from app.scenarios.schema import ChecklistItem, Scenario
|
||||
|
||||
|
||||
class ScenarioError(Exception):
|
||||
"""Ошибка библиотеки. Текст пишется для методиста, не для программиста."""
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict:
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as exc:
|
||||
raise ScenarioError(f"{path.name}: битый YAML — {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ScenarioError(f"{path.name}: ожидался словарь верхнего уровня")
|
||||
return data
|
||||
|
||||
|
||||
def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]:
|
||||
"""Общий чек-лист по классификатору плюс локальные дополнения.
|
||||
|
||||
Наследуются все пункты базы, локальные перекрывают одноимённые и добавляют
|
||||
свои. Пункт без `fact` допустим: «представьтесь» не добывает факт,
|
||||
но остаётся частью эталонного опроса.
|
||||
"""
|
||||
merged: dict[str, dict] = {item["id"]: dict(item) for item in base}
|
||||
for item in local:
|
||||
merged.setdefault(item["id"], {}).update(item)
|
||||
return [ChecklistItem.model_validate(item) for item in merged.values()]
|
||||
|
||||
|
||||
def _derive_ground_truth(scenario: Scenario) -> Scenario:
|
||||
"""Эталон собирается кодом. Из YAML берутся только нормализованные
|
||||
адрес и число пострадавших — остальное перезаписывается."""
|
||||
hidden = {fact.id for fact in scenario.facts if fact.hidden}
|
||||
required = [
|
||||
item.fact
|
||||
for item in scenario.checklist
|
||||
if item.fact and item.fact not in hidden
|
||||
]
|
||||
scenario.ground_truth.incident_type = scenario.type
|
||||
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
|
||||
scenario.ground_truth.required_facts = required
|
||||
return scenario
|
||||
|
||||
|
||||
def load_file(path: Path, root: Path) -> Scenario:
|
||||
raw = _read_yaml(path)
|
||||
|
||||
declared = raw.get("ground_truth") or {}
|
||||
forbidden = {"incident_type", "dds", "required_facts"} & set(declared)
|
||||
if forbidden:
|
||||
raise ScenarioError(
|
||||
f"{path.name}: {', '.join(sorted(forbidden))} в ground_truth выводится кодом "
|
||||
"и руками не пишется — иначе факты и эталон разъедутся"
|
||||
)
|
||||
|
||||
extends = raw.get("extends")
|
||||
if extends:
|
||||
base_path = root / extends
|
||||
if not base_path.exists():
|
||||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
||||
base = _read_yaml(base_path).get("checklist", [])
|
||||
raw["checklist"] = [
|
||||
item.model_dump(exclude_none=True)
|
||||
for item in _merge_checklist(base, raw.get("checklist", []))
|
||||
]
|
||||
|
||||
try:
|
||||
scenario = Scenario.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
where = ".".join(str(part) for part in first["loc"])
|
||||
raise ScenarioError(f"{path.name}: {where} — {first['msg']}") from exc
|
||||
|
||||
known = scenario.fact_ids()
|
||||
for item in scenario.checklist:
|
||||
if item.fact and item.fact not in known:
|
||||
raise ScenarioError(
|
||||
f"{path.name}: пункт {item.id} ссылается на факт {item.fact}, которого нет"
|
||||
)
|
||||
if not item.question:
|
||||
raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса")
|
||||
|
||||
for fact in scenario.facts:
|
||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question_id and question_id not in {item.id for item in scenario.checklist}:
|
||||
raise ScenarioError(
|
||||
f"{path.name}: факт {fact.id} раскрывается вопросом {question_id}, "
|
||||
"которого нет в чек-листе"
|
||||
)
|
||||
|
||||
return _derive_ground_truth(scenario)
|
||||
|
||||
|
||||
def load_library(root: Path) -> list[Scenario]:
|
||||
"""Все сценарии каталога. Подкаталог `checklists/` — не сценарии."""
|
||||
if not root.exists():
|
||||
raise ScenarioError(f"каталог сценариев не найден: {root}")
|
||||
|
||||
scenarios: list[Scenario] = []
|
||||
seen: dict[str, Path] = {}
|
||||
for path in sorted(root.glob("*.yaml")):
|
||||
scenario = load_file(path, root)
|
||||
if scenario.id in seen:
|
||||
raise ScenarioError(f"{path.name}: id {scenario.id} уже занят {seen[scenario.id].name}")
|
||||
seen[scenario.id] = path
|
||||
scenarios.append(scenario)
|
||||
|
||||
if not scenarios:
|
||||
raise ScenarioError(f"в {root} нет ни одного сценария")
|
||||
return scenarios
|
||||
125
backend/app/scenarios/schema.py
Normal file
125
backend/app/scenarios/schema.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Схема сценария. Формат: docs/spec/SCENARIO-FORMAT.md
|
||||
|
||||
Сценарий — контент, а не код: его пишет методист, и читается он глазами.
|
||||
Поэтому схема строгая (`extra="forbid"`): опечатка в имени поля должна падать
|
||||
на старте приложения, а не тихо игнорироваться и всплывать посреди занятия.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level
|
||||
from app.domain.events import Mood
|
||||
|
||||
|
||||
class Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ArcStage(Strict):
|
||||
stage: str
|
||||
mood: Mood
|
||||
|
||||
|
||||
class Persona(Strict):
|
||||
base: str
|
||||
arc: list[ArcStage] = []
|
||||
|
||||
|
||||
class Background(Strict):
|
||||
loop: str
|
||||
gain_db: float = -18
|
||||
|
||||
|
||||
class RevealOn(Strict):
|
||||
"""Два вида условий: вопрос из чек-листа либо подход оператора."""
|
||||
|
||||
question: str | None = None
|
||||
approach: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one(self):
|
||||
if bool(self.question) == bool(self.approach):
|
||||
raise ValueError("reveal_on: ровно одно из `question` или `approach`")
|
||||
return self
|
||||
|
||||
|
||||
class Fact(Strict):
|
||||
id: str
|
||||
value: str
|
||||
hidden: bool = False
|
||||
reveal_on: RevealOn | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def hidden_needs_condition(self):
|
||||
if self.hidden and (self.reveal_on is None or not self.reveal_on.approach):
|
||||
raise ValueError(
|
||||
f"факт {self.id}: hidden требует reveal_on.approach — "
|
||||
"скрытый факт не раскрывается прямым вопросом"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ChecklistItem(Strict):
|
||||
id: str
|
||||
question: str | None = None
|
||||
fact: str | None = None
|
||||
|
||||
|
||||
class EraGlonass(Strict):
|
||||
vin: str
|
||||
coords: dict
|
||||
passengers: int
|
||||
impact_force: str
|
||||
|
||||
|
||||
class Tree(Strict):
|
||||
pregenerated: bool = False
|
||||
|
||||
|
||||
class GroundTruth(Strict):
|
||||
"""Выводится кодом. В YAML допускаются только нормализованные ожидания
|
||||
(адрес и число пострадавших): вывести «улица Ленина, 14» из фразы
|
||||
«улица Ленина, 14, квартира 47, 5-й этаж» кодом нельзя, а сверять оценку
|
||||
с сырым текстом факта — значит штрафовать курсанта за правильный ответ.
|
||||
|
||||
Всё остальное загрузчик проставляет сам и запрещает писать руками —
|
||||
иначе генератор сценариев рассинхронизирует факты и эталон.
|
||||
"""
|
||||
|
||||
incident_type: IncidentType | None = None
|
||||
dds: DDSCode | None = None
|
||||
required_facts: list[str] = []
|
||||
address: str | None = None
|
||||
victims: int | None = None
|
||||
|
||||
|
||||
class Scenario(Strict):
|
||||
id: str
|
||||
title: str
|
||||
type: IncidentType
|
||||
level: Level
|
||||
topics: list[str] = []
|
||||
modes: list[str] = ["training"]
|
||||
extends: str | None = None
|
||||
|
||||
persona: Persona
|
||||
background: Background | None = None
|
||||
first_line: str
|
||||
|
||||
facts: list[Fact] = []
|
||||
checklist: list[ChecklistItem] = []
|
||||
required_fields: list[str] = Field(default_factory=list)
|
||||
ground_truth: GroundTruth = GroundTruth()
|
||||
era_glonass: EraGlonass | None = None
|
||||
tree: Tree = Tree()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def era_only_for_era_type(self):
|
||||
if self.era_glonass is not None and self.type is not IncidentType.ERA_GLONASS:
|
||||
raise ValueError("era_glonass задан, но type не era_glonass")
|
||||
if self.type is IncidentType.ERA_GLONASS and self.era_glonass is None:
|
||||
raise ValueError("type era_glonass требует блок era_glonass")
|
||||
return self
|
||||
|
||||
def fact_ids(self) -> set[str]:
|
||||
return {fact.id for fact in self.facts}
|
||||
53
backend/app/scenarios/store.py
Normal file
53
backend/app/scenarios/store.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""Библиотека в памяти процесса и её заливка в БД.
|
||||
|
||||
В памяти живёт то, что читает голосовой контур и оценка; в БД — то, из чего
|
||||
преподаватель выбирает сценарий и что переживает перезапуск.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Scenario as ScenarioRow
|
||||
from app.scenarios.loader import load_library
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
|
||||
|
||||
def set_library(scenarios: list[Scenario]) -> None:
|
||||
_library.clear()
|
||||
_library.update({scenario.id: scenario for scenario in scenarios})
|
||||
|
||||
|
||||
def get(scenario_id: str) -> Scenario | None:
|
||||
return _library.get(scenario_id)
|
||||
|
||||
|
||||
def all_scenarios() -> list[Scenario]:
|
||||
return list(_library.values())
|
||||
|
||||
|
||||
def load_from_disk(root: Path) -> list[Scenario]:
|
||||
scenarios = load_library(root)
|
||||
set_library(scenarios)
|
||||
return scenarios
|
||||
|
||||
|
||||
async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
|
||||
"""Залить библиотеку в БД. Повторный запуск обновляет, а не дублирует."""
|
||||
for scenario in scenarios:
|
||||
row = await db.scalar(select(ScenarioRow).where(ScenarioRow.id == scenario.id))
|
||||
payload = scenario.model_dump(mode="json")
|
||||
if row is None:
|
||||
row = ScenarioRow(id=scenario.id)
|
||||
db.add(row)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
row.level = scenario.level.value
|
||||
row.topics = scenario.topics
|
||||
row.modes = scenario.modes
|
||||
row.body = payload
|
||||
await db.commit()
|
||||
return len(scenarios)
|
||||
Loading…
Reference in a new issue