Главный предмет проверки в билетах заказчика — адрес: названный заявителем часто неверен, а настоящий добывается переспросом («ул. Станционная, 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 чистый.
173 lines
7.8 KiB
Python
173 lines
7.8 KiB
Python
"""Загрузчик библиотеки сценариев.
|
||
|
||
Проверяет **все** YAML на старте приложения и падает с внятным сообщением
|
||
при первом же нарушении: сломанный сценарий, найденный посреди занятия, —
|
||
сценарий, которого не должно случиться (docs/arch/BACKEND.md).
|
||
"""
|
||
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
from pydantic import ValidationError
|
||
|
||
from app.domain import ekp
|
||
from app.domain.classifiers import DDS_BY_INCIDENT
|
||
from app.scenarios.schema import ChecklistItem, Scenario
|
||
|
||
|
||
class ScenarioError(Exception):
|
||
"""Ошибка библиотеки. Текст пишется для методиста, не для программиста."""
|
||
|
||
|
||
def _read_yaml(path: Path) -> dict:
|
||
try:
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
except yaml.YAMLError as exc:
|
||
raise ScenarioError(f"{path.name}: битый YAML — {exc}") from exc
|
||
if not isinstance(data, dict):
|
||
raise ScenarioError(f"{path.name}: ожидался словарь верхнего уровня")
|
||
return data
|
||
|
||
|
||
def _merge_checklist(base: list[dict], local: list[dict]) -> list[ChecklistItem]:
|
||
"""Общий чек-лист по классификатору плюс локальные дополнения.
|
||
|
||
Наследуются все пункты базы, локальные перекрывают одноимённые и добавляют
|
||
свои. Пункт без `fact` допустим: «представьтесь» не добывает факт,
|
||
но остаётся частью эталонного опроса.
|
||
"""
|
||
merged: dict[str, dict] = {item["id"]: dict(item) for item in base}
|
||
for item in local:
|
||
merged.setdefault(item["id"], {}).update(item)
|
||
return [ChecklistItem.model_validate(item) for item in merged.values()]
|
||
|
||
|
||
def _derive_ground_truth(scenario: Scenario) -> Scenario:
|
||
"""Эталон собирается кодом. Из YAML берутся только нормализованные
|
||
адрес и число пострадавших — остальное перезаписывается."""
|
||
hidden = {fact.id for fact in scenario.facts if fact.hidden}
|
||
required = [
|
||
item.fact
|
||
for item in scenario.checklist
|
||
if item.fact and item.fact not in hidden
|
||
]
|
||
scenario.ground_truth.incident_type = scenario.type
|
||
scenario.ground_truth.dds = DDS_BY_INCIDENT[scenario.type]
|
||
scenario.ground_truth.required_facts = required
|
||
|
||
# Признаки → код ЕКП → список оповещения. Сценарий без признаков остаётся
|
||
# рабочим: такие оцениваются по старым полям, пока их не разметят (lct-34).
|
||
if scenario.signs:
|
||
found = ekp.by_signs(scenario.signs)
|
||
if found is None:
|
||
raise ScenarioError(
|
||
f"{scenario.id}: признаки {scenario.signs} не найдены в классификаторе ЕКП "
|
||
f"версии {ekp.reference().version}"
|
||
)
|
||
scenario.ground_truth.incident_code = found.code
|
||
scenario.ground_truth.notify = ekp.notify_list(found.code)
|
||
return scenario
|
||
|
||
|
||
COMMON = "checklists/common.yaml"
|
||
|
||
|
||
def _common_not_questions(root: Path) -> list[str]:
|
||
common = root / COMMON
|
||
if not common.exists():
|
||
return []
|
||
return list(_read_yaml(common).get("not_questions", []))
|
||
|
||
|
||
def _common_checklist(root: Path, wanted: set[str]) -> list[dict]:
|
||
"""Общие пункты опроса — по требованию сценария, а не всем подряд.
|
||
|
||
В `common.yaml` лежат формулировки пунктов, которые не принадлежат ни одному
|
||
классификатору: проверка адреса, контакты заявителя. Сценарий подключает их,
|
||
объявив пункт с тем же `id` и без текста, — текст и примеры подставятся сюда.
|
||
Насильно добавлять их во все сценарии нельзя: непрочитанный пункт чек-листа
|
||
штрафует курсанта отметкой E1 за вопрос, которого сценарий не требовал.
|
||
"""
|
||
common = root / COMMON
|
||
if not common.exists():
|
||
return []
|
||
return [
|
||
item for item in _read_yaml(common).get("checklist", []) if item.get("id") in wanted
|
||
]
|
||
|
||
|
||
def load_file(path: Path, root: Path) -> Scenario:
|
||
raw = _read_yaml(path)
|
||
raw["not_questions"] = _common_not_questions(root) + list(raw.get("not_questions", []))
|
||
|
||
declared = raw.get("ground_truth") or {}
|
||
forbidden = {"incident_type", "dds", "required_facts", "incident_code", "notify"} & set(declared)
|
||
if forbidden:
|
||
raise ScenarioError(
|
||
f"{path.name}: {', '.join(sorted(forbidden))} в ground_truth выводится кодом "
|
||
"и руками не пишется — иначе факты и эталон разъедутся"
|
||
)
|
||
|
||
own = raw.get("checklist", [])
|
||
base = _common_checklist(root, {item.get("id") for item in own})
|
||
extends = raw.get("extends")
|
||
if extends:
|
||
base_path = root / extends
|
||
if not base_path.exists():
|
||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
||
base = base + _read_yaml(base_path).get("checklist", [])
|
||
if base:
|
||
raw["checklist"] = [
|
||
item.model_dump(exclude_none=True) for item in _merge_checklist(base, own)
|
||
]
|
||
|
||
try:
|
||
scenario = Scenario.model_validate(raw)
|
||
except ValidationError as exc:
|
||
first = exc.errors()[0]
|
||
where = ".".join(str(part) for part in first["loc"])
|
||
raise ScenarioError(f"{path.name}: {where} — {first['msg']}") from exc
|
||
|
||
known = scenario.fact_ids()
|
||
for item in scenario.checklist:
|
||
if item.fact and item.fact not in known:
|
||
raise ScenarioError(
|
||
f"{path.name}: пункт {item.id} ссылается на факт {item.fact}, которого нет"
|
||
)
|
||
if not item.question:
|
||
raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса")
|
||
|
||
item_ids = {item.id for item in scenario.checklist}
|
||
for fact in scenario.facts:
|
||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||
if question_id and question_id not in item_ids:
|
||
raise ScenarioError(
|
||
f"{path.name}: факт {fact.id} раскрывается вопросом {question_id}, "
|
||
"которого нет в чек-листе"
|
||
)
|
||
if fact.refine_on and fact.refine_on not in item_ids:
|
||
raise ScenarioError(
|
||
f"{path.name}: факт {fact.id} уточняется вопросом {fact.refine_on}, "
|
||
"которого нет в чек-листе"
|
||
)
|
||
|
||
return _derive_ground_truth(scenario)
|
||
|
||
|
||
def load_library(root: Path) -> list[Scenario]:
|
||
"""Все сценарии каталога. Подкаталог `checklists/` — не сценарии."""
|
||
if not root.exists():
|
||
raise ScenarioError(f"каталог сценариев не найден: {root}")
|
||
|
||
scenarios: list[Scenario] = []
|
||
seen: dict[str, Path] = {}
|
||
for path in sorted(root.glob("*.yaml")):
|
||
scenario = load_file(path, root)
|
||
if scenario.id in seen:
|
||
raise ScenarioError(f"{path.name}: id {scenario.id} уже занят {seen[scenario.id].name}")
|
||
seen[scenario.id] = path
|
||
scenarios.append(scenario)
|
||
|
||
if not scenarios:
|
||
raise ScenarioError(f"в {root} нет ни одного сценария")
|
||
return scenarios
|