lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""Схема сценария. Формат: 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):
|
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
|
|
|
|
"""Факт, который оператор должен добыть.
|
|
|
|
|
|
|
|
|
|
|
|
`refined` — второе, настоящее значение. Нужно там, где заявитель называет
|
|
|
|
|
|
ориентир или адрес, по которому стоит сам: «ул. Станционная, 28» оказывается
|
|
|
|
|
|
Королёвом, «дом с библиотекой №193» — домом 11 по улице Грина. Памятка
|
|
|
|
|
|
заказчика называет это обычным делом, а не краем сценария, и в билетах такой
|
|
|
|
|
|
вызов не один (docs/spec/TICKETS.md).
|
|
|
|
|
|
|
|
|
|
|
|
Пока оператор не переспросил, звонящий говорит `value`; после уточняющего
|
|
|
|
|
|
вопроса — `refined`, и эталон сверяется уже с ним.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
id: str
|
|
|
|
|
|
value: str
|
|
|
|
|
|
hidden: bool = False
|
|
|
|
|
|
reveal_on: RevealOn | None = None
|
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
|
|
|
|
refined: str | None = None
|
|
|
|
|
|
refine_on: str | None = Field(
|
|
|
|
|
|
default=None, description="Пункт чек-листа, уточняющий этот факт"
|
|
|
|
|
|
)
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
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
|
|
|
|
@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
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
class ChecklistItem(Strict):
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
"""Пункт эталонного опроса.
|
|
|
|
|
|
|
|
|
|
|
|
`examples` — другие формулировки того же вопроса. Без них матчинг
|
|
|
|
|
|
по эмбеддингам не отличает вопрос от не-вопроса: на e5 «Оставайтесь
|
|
|
|
|
|
на линии» ближе к пункту чек-листа, чем половина настоящих вопросов.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
id: str
|
|
|
|
|
|
question: str | None = None
|
|
|
|
|
|
fact: str | None = None
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
examples: list[str] = []
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
incident_code: str | None = None
|
|
|
|
|
|
notify: list[str] = []
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
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
|
|
|
|
|
|
|
feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)
Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.
- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
и пересчитываются при каждой правке признаков; добавленная вручную служба
не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
dds_choice больше не считается — список оповещения производен от признаков,
и штрафовать за него отдельно значит наказать дважды за одну ошибку.
Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.
113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
2026-09-19 20:11:50 +03:00
|
|
|
|
# Признаки происшествия по ЕКП — то, что курсант обязан проставить в карточке.
|
|
|
|
|
|
# Из них загрузчик выводит код и список оповещения (domain/ekp.py).
|
|
|
|
|
|
signs: list[str] = Field(default_factory=list, max_length=3)
|
|
|
|
|
|
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
facts: list[Fact] = []
|
|
|
|
|
|
checklist: list[ChecklistItem] = []
|
|
|
|
|
|
required_fields: list[str] = Field(default_factory=list)
|
lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.
Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».
Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.
Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.
Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.
Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00
|
|
|
|
# Реплики оператора, которые вопросом не являются: «успокойтесь»,
|
|
|
|
|
|
# «оставайтесь на линии». Общий список — checklists/common.yaml,
|
|
|
|
|
|
# сценарий может дополнить своими.
|
|
|
|
|
|
not_questions: list[str] = Field(default_factory=list)
|
lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
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}
|