Главный предмет проверки в билетах заказчика — адрес: названный заявителем часто неверен, а настоящий добывается переспросом («ул. Станционная, 28» оказывается Королёвом). Памятка АРМ-112 называет это обычным делом. До этой правки оценка работала наоборот: ground_truth.address сравнивался с единственным значением факта, поэтому курсант, правильно переспросивший и записавший настоящий адрес, получал расхождение с эталоном, а записавший ориентир — зачёт. - Схема: у факта появились refined и refine_on, задаются только вместе. - Слот-автомат различает уточнение и повтор: повтор раздражает звонящего, уточнение — нет, оператор спросил о другом и получил другое. - Звонящий поправляется отдельной репликой, а не повторяет прежнее значение; офлайн-таблица получила секцию refine. - Оценка: метрика address_refined (E1, опрос). Сделана отдельной, а не правкой метрики address: «записал не тот адрес» и «не спросил» — разные навыки и разные компетенции в радаре. - Общие пункты чек-листа подключаются по требованию сценария: формулировки в checklists/common.yaml, сценарий объявляет пункт с тем же id без текста. Добавлять «уточните адрес» во все сценарии нельзя — неотработанный пункт штрафует за вопрос, которого сценарий не требовал. - scenarios/fire-private-house-l3.yaml — первый сценарий из библиотеки заказчика (билет 3, вызов 1). Риск механики проверен на живой модели: «Назовите адрес» и «Это точно Москва?» эмбеддинги не путают, тест это фиксирует. 125 тестов зелёных (12 новых), make typecheck чистый.
168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""Схема сценария. Формат: 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):
|
||
"""Факт, который оператор должен добыть.
|
||
|
||
`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)
|
||
|
||
facts: list[Fact] = []
|
||
checklist: list[ChecklistItem] = []
|
||
required_fields: list[str] = Field(default_factory=list)
|
||
# Реплики оператора, которые вопросом не являются: «успокойтесь»,
|
||
# «оставайтесь на линии». Общий список — 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 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}
|