"""Схема сценария. Формат: docs/spec/SCENARIO-FORMAT.md Сценарий — контент, а не код: его пишет методист, и читается он глазами. Поэтому схема строгая (`extra="forbid"`): опечатка в имени поля должна падать на старте приложения, а не тихо игнорироваться и всплывать посреди занятия. """ from pydantic import BaseModel, ConfigDict, Field, model_validator from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome from app.domain.events import Mood from app.domain.kio import EDITABLE_KIO_FIELDS, KIO from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus from app.scoring.taxonomy import METRIC_MAP 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 = Field(min_length=1) class Fact(Strict): """Факт, который оператор должен добыть. `refined` — второе, настоящее значение. Нужно там, где заявитель называет ориентир или адрес, по которому стоит сам: «ул. Станционная, 28» оказывается Королёвом, «дом с библиотекой №193» — домом 11 по улице Грина. Памятка заказчика называет это обычным делом, а не краем сценария, и в билетах такой вызов не один (docs/spec/TICKETS.md). Пока оператор не переспросил, звонящий говорит `value`; после уточняющего вопроса — `refined`, и эталон сверяется уже с ним. """ id: str value: str reveal_on: RevealOn | None = None refined: str | None = None refine_on: str | None = Field( default=None, description="Пункт чек-листа, уточняющий этот факт" ) @model_validator(mode="after") def refinement_needs_both_halves(self): if bool(self.refined) != bool(self.refine_on): raise ValueError( f"факт {self.id}: refined и refine_on задаются вместе — " "иначе уточнение либо нечем вызвать, либо нечего сказать" ) return self class ChecklistItem(Strict): """Пункт эталонного опроса. `examples` — другие формулировки того же вопроса. Без них матчинг по эмбеддингам не отличает вопрос от не-вопроса: на e5 «Оставайтесь на линии» ближе к пункту чек-листа, чем половина настоящих вопросов. """ id: str question: str | None = None fact: str | None = None examples: list[str] = [] 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 incident_code: str | None = None notify: list[str] = [] required_facts: list[str] = [] address: str | None = None victims: int | None = None class DdsDecision(Strict): """Эталон первичного решения службы по данной карточке. Профильность по ЕКП сама по себе не исключает дубль или территориальный отказ, поэтому такие исключения задаются явно в сценарии. """ expected: str = Field(default="accept", pattern="^(accept|decline)$") reason: str | None = None @model_validator(mode="after") def decline_needs_reason(self): if self.expected == "decline" and not (self.reason and self.reason.strip()): raise ValueError("dds_decision.reason обязателен для эталонного отказа") return self class AdjacentTimelineEntry(Strict): """Отметка смежной службы, которую симуляция ставит по сроку. `after_seconds` отсчитывается от принятия карточки курсантом; пауза занятия таймлайн замораживает. Комментарий обязателен: пустая отметка смежной службы учила бы курсанта тому, за что сама система ставит D5. """ service: str = Field(min_length=1) after_seconds: int = Field(ge=0, le=3600) status: ServiceStatus comment: str = Field(min_length=1) @model_validator(mode="after") def comment_not_blank(self): if not self.comment.strip(): raise ValueError(f"смежная служба {self.service}: комментарий отметки пуст") return self class CrewRequest(Strict): """Вводная «бригада просит смежников»: входящий вызов своей бригады. Курсант сверяется с карточкой и либо передаёт бригаде, что смежная служба реагирует, либо связывается с ней сам. `after_seconds` — от принятия карточки, как у таймлайна. """ after_seconds: int = Field(ge=0, le=3600) service: str = Field(min_length=1) text: str = Field(min_length=1) #: Список оповещения, если у сценария нет признаков ЕКП: главная служба #: по коду ДДС. Тот же запасной вариант использует пульт при сборке карточки. FALLBACK_NOTIFY: dict[str, str] = { "01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба", } 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 # Признаки происшествия по ЕКП — то, что курсант обязан проставить в карточке. # Из них загрузчик выводит код и список оповещения (domain/ekp.py). signs: list[str] = Field(default_factory=list, max_length=3) # Билет — единица занятия у заказчика: три вызова подряд, разные службы # (docs/spec/TICKETS.md). Преподаватель выбирает билет, а не сценарий. ticket: int | None = None position: int | None = Field( default=None, ge=1, le=3, description="Номер вызова в билете" ) # Чем вызов заканчивается правильно. По умолчанию — карточка и выезд; # справка и передача в другой регион разбираются в lct-36. outcome: Outcome = Outcome.CARD dds_decision: DdsDecision = DdsDecision() # Готовая КИО, сформированная курсантом и утверждённая преподавателем. # Для системных сценариев поле отсутствует; ДДС использует снимок как # исходную карточку вместо реконструкции её из кратких фактов. student_card: KIO | None = None facts: list[Fact] = [] checklist: list[ChecklistItem] = [] required_fields: list[str] = Field(default_factory=list) # Учебные веса задаёт преподаватель в утверждаемом сценарии. Ноль # отключает метрику из знаменателя; значения методики предварительные. score_weights: dict[str, float] = Field(default_factory=dict) # Реплики оператора, которые вопросом не являются: «успокойтесь», # «оставайтесь на линии». Общий список — checklists/common.yaml, # сценарий может дополнить своими. not_questions: list[str] = Field(default_factory=list) ground_truth: GroundTruth = GroundTruth() era_glonass: EraGlonass | None = None tree: Tree = Tree() # Реагирование смежных служб в карточке ДДС: сценарный таймлайн отметок # и не больше одной вводной «бригада просит смежников» (lct: issue 68). adjacent_timeline: list[AdjacentTimelineEntry] = Field(default_factory=list) crew_request: CrewRequest | None = None @model_validator(mode="after") def valid_score_weights(self): unknown = self.score_weights.keys() - METRIC_MAP.keys() if unknown: raise ValueError( f"неизвестные метрики score_weights: {', '.join(sorted(unknown))}" ) if any(not 0 <= weight <= 10 for weight in self.score_weights.values()): raise ValueError("score_weights: каждый вес должен быть от 0 до 10") return self @model_validator(mode="after") def valid_required_fields(self): duplicates = sorted({path for path in self.required_fields if self.required_fields.count(path) > 1}) if duplicates: raise ValueError( f"required_fields: повторяются поля: {', '.join(duplicates)}" ) unavailable = sorted(set(self.required_fields) - EDITABLE_KIO_FIELDS) if unavailable: raise ValueError( "required_fields: поля отсутствуют в форме КИО или заполняются системой: " + ", ".join(unavailable) ) if self.outcome is not Outcome.CARD and self.required_fields: raise ValueError( "required_fields должны быть пустыми, если карточка КИО не создаётся" ) return self @model_validator(mode="after") def unique_reference_ids(self): for label, values in ( ("id фактов", [fact.id for fact in self.facts]), ("id пунктов чек-листа", [item.id for item in self.checklist]), ("признаки ЕКП", self.signs), ): duplicates = sorted({value for value in values if values.count(value) > 1}) if duplicates: raise ValueError(f"{label} должны быть уникальны: {', '.join(duplicates)}") return self @model_validator(mode="after") def ticket_needs_position(self): if (self.ticket is None) != (self.position is None): raise ValueError( "ticket и position задаются вместе: билет без номера вызова неполон" ) return self @model_validator(mode="after") def adjacent_timeline_follows_status_machine(self): """Кривой таймлайн валит загрузку сценария, а не занятие: симуляция пишет отметки тем же автоматом переходов, что и диспетчер.""" chains: dict[str, ServiceStatus] = {} for entry in sorted(self.adjacent_timeline, key=lambda item: item.after_seconds): state = chains.get(entry.service, ServiceStatus.ADDED) if entry.status not in NEXT[state]: raise ValueError( f"adjacent_timeline: {entry.service} — после " f"«{SERVICE_STATUS_LABELS[state]}» недопустим статус " f"«{SERVICE_STATUS_LABELS[entry.status]}»" ) chains[entry.service] = entry.status return self def notify_services(self) -> list[str]: """Список оповещения карточки пульта, как его соберёт `build_card`: карточка курсанта, иначе эталон по ЕКП, иначе главная служба по коду.""" if self.student_card is not None: return list(self.student_card.notify) if self.ground_truth.notify: return list(self.ground_truth.notify) service = self.ground_truth.dds.value if self.ground_truth.dds else None return [FALLBACK_NOTIFY[service]] if service in FALLBACK_NOTIFY else [] @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}