lct-hack/backend/app/scenarios/schema.py

348 lines
16 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 typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, 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.address import address_matches
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 DdsCardError(Strict):
"""Заложенное расхождение готовой карточки с эталоном для занятия ДДС."""
field: Literal["address"]
card_value: str = Field(min_length=3)
@field_validator("card_value")
@classmethod
def nonempty_card_value(cls, value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("dds_card_error.card_value: укажите неверный адрес карточки")
return value
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()
dds_card_error: DdsCardError | None = None
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
# Для системных сценариев поле отсутствует; ДДС использует снимок как
# исходную карточку вместо реконструкции её из кратких фактов.
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 valid_dds_card_error(self):
error = self.dds_card_error
if error is None:
return self
if not self.ground_truth.address or not self.ground_truth.address.strip():
raise ValueError("dds_card_error: для ошибки адреса нужен ground_truth.address")
if self.student_card is not None:
raise ValueError("dds_card_error: используйте отдельный учебный сценарий без student_card")
if address_matches(self.ground_truth.address, error.card_value):
raise ValueError("dds_card_error: адрес карточки должен отличаться от эталона")
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}