lct-hack/backend/app/scenarios/schema.py
Ivan Gerasimov 842a333d16 feat: уточнение адреса — место происшествия не равно адресу заявителя (lct-35)
Главный предмет проверки в билетах заказчика — адрес: названный заявителем
часто неверен, а настоящий добывается переспросом («ул. Станционная, 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 чистый.
2026-09-19 20:26:49 +03:00

168 lines
6.7 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):
"""Факт, который оператор должен добыть.
`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}