lct-hack/backend/app/scenarios/schema.py
2026-09-21 19:56:13 +03:00

196 lines
8.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, Outcome
from app.domain.events import Mood
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 | 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):
"""Факт, который оператор должен добыть.
`refined` — второе, настоящее значение. Нужно там, где заявитель называет
ориентир или адрес, по которому стоит сам: «ул. Станционная, 28» оказывается
Королёвом, «дом с библиотекой №193» — домом 11 по улице Грина. Памятка
заказчика называет это обычным делом, а не краем сценария, и в билетах такой
вызов не один (docs/spec/TICKETS.md).
Пока оператор не переспросил, звонящий говорит `value`; после уточняющего
вопроса — `refined`, и эталон сверяется уже с ним.
"""
id: str
value: str
hidden: bool = False
reveal_on: RevealOn | None = None
refined: str | None = None
refine_on: str | None = Field(
default=None, description="Пункт чек-листа, уточняющий этот факт"
)
@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
@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 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
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()
@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 ticket_needs_position(self):
if (self.ticket is None) != (self.position is None):
raise ValueError("ticket и position задаются вместе: билет без номера вызова неполон")
return self
@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}