lct-hack/backend/app/scenarios/schema.py
Ivan Gerasimov 6b2c96bab6 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

125 lines
4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Схема сценария. Формат: 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}