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 с кэшем.
This commit is contained in:
Ivan Gerasimov 2026-09-17 13:57:43 +03:00
commit 7c97310938
22 changed files with 1073 additions and 26 deletions

View file

@ -30,8 +30,8 @@ test: ## Тесты бэкенда
typecheck: ## Проверить фронтенд компилятором
npm --prefix frontend run typecheck
models: ## Скачать веса GigaAM (STT) и Silero (TTS) в backend/models/
@echo "не реализовано — карточка tasks/lct-02-latency-baseline.md"; exit 1
models: ## Скачать модели в backend/models/ (сейчас — эмбеддинги; GigaAM и Silero — lct-02)
cd backend && $(UV) run python scripts/models.py
seed: ## Залить сценарии из /scenarios в БД
cd backend && $(UV) run python scripts/seed.py
@ -42,8 +42,8 @@ migrate: ## Накатить миграции
revision: ## Создать миграцию: make revision m="что изменилось"
cd backend && $(UV) run alembic revision --autogenerate -m "$(m)"
repl: ## Текстовый диалог со звонящим без голоса
@echo "не реализовано — карточка tasks/lct-07-caller-slots.md"; exit 1
repl: ## Текстовый диалог со звонящим без голоса: make repl s=<сценарий>
cd backend && $(UV) run python scripts/repl.py "$(s)"
pregen: ## Дерево диалога и WAV первых реплик для офлайна
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1

View file

@ -7,7 +7,8 @@ ENV UV_PROJECT_ENVIRONMENT=/usr/local PYTHONUNBUFFERED=1
COPY pyproject.toml ./
RUN uv pip install --system fastapi "uvicorn[standard]" "pydantic>=2.7" pydantic-settings \
"sqlalchemy[asyncio]" alembic asyncpg httpx pyyaml structlog
"sqlalchemy[asyncio]" alembic asyncpg httpx pyyaml structlog \
onnxruntime tokenizers numpy
COPY . .
EXPOSE 8000

View file

@ -35,16 +35,18 @@ _adapter = TypeAdapter(TraineeToServer)
def _next_hint(state) -> tuple[str, str] | None:
"""Следующий пункт чек-листа, который ещё не подсказывали.
"""Следующий неотработанный пункт чек-листа, который ещё не подсказывали.
Порядок временный: пока нет слот-автомата (lct-07), подсказка идёт
по порядку чек-листа, а не по реально неотработанным пунктам.
Перевод на слот-автомат — карточка lct-14.
«Неотработанный» знает слот-автомат: пункт, о котором оператор уже спросил
своими словами, подсказывать бессмысленно. Без модели эмбеддингов автомата
нет — тогда подсказка идёт по порядку чек-листа.
"""
if state.slots is not None:
candidates = state.slots.unasked()
else:
scenario = store.get(state.scenario_id)
if scenario is None:
return None
for item in scenario.checklist:
candidates = scenario.checklist if scenario else []
for item in candidates:
if item.id not in state.hints_shown and item.question:
return item.id, item.question
return None

View file

@ -25,6 +25,9 @@ from app.domain.events import (
ReferenceStarted,
SessionEnded,
)
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.scenarios import store
from app.session.hub import hub
from app.session.state import SessionState, now_utc
@ -62,6 +65,10 @@ async def _start(session_id: UUID, event) -> None:
attempt=attempt,
)
)
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(scenario, embedder)
state.persona = PersonaState(scenario.persona)
state.on_event("call.incoming")
hub.start_ticker(session_id)

View file

View file

@ -0,0 +1,103 @@
"""Реплика звонящего.
`TemplateCaller` работает без LLM: говорит только раскрытыми фактами и заготовками
по настроению. Это и офлайн-запасной путь, и режим `make repl`, пока нет ключа.
Звонящий на LLM встанет за тот же интерфейс `Caller`.
70% восприятия эмоции даёт текст: обрывки, повторы, незаконченные фразы.
Заготовки написаны так же — «алло! алло!», а не «я взволнован».
"""
from dataclasses import dataclass
from typing import Protocol
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood
@dataclass
class CallerLine:
text: str
mood: Mood
class Caller(Protocol):
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: ...
#: Реплика, когда оператор спросил не то или непонятно. Выбор по номеру реплики,
#: а не случайный: сценарий занятия должен звучать одинаково у каждой группы.
FILLERS: dict[Mood, list[str]] = {
Mood.PANIC: [
"Алло?! Вы меня слышите?! Помогите быстрее!",
"Что?! Я не понимаю! Приезжайте!",
"Господи... быстрее, пожалуйста!",
],
Mood.WORRIED: [
"Простите, я не поняла вопрос...",
"Что именно вам сказать?",
],
Mood.CALM: [
"Уточните, пожалуйста, что вас интересует.",
"Не понял вопрос.",
],
Mood.AGGRESSIVE: [
"Да вы издеваетесь?! Хватит болтать, высылайте!",
"Сколько можно?! Люди горят!",
],
Mood.CONFUSED: [
"А? Кто это... что вы говорите?",
"Подождите... я забыл, что хотел...",
],
}
#: Как звонящий подаёт раскрытый факт.
REVEAL: dict[Mood, str] = {
Mood.PANIC: "{fact}! Быстрее!",
Mood.WORRIED: "{fact}.",
Mood.CALM: "{fact}.",
Mood.AGGRESSIVE: "{fact}! Записали?!",
Mood.CONFUSED: "Так... {fact}... кажется.",
}
#: Повторный вопрос по уже сказанному.
REPEAT: dict[Mood, str] = {
Mood.PANIC: "Я же сказал — {fact}! Записывайте!",
Mood.WORRIED: "Я ведь уже говорила: {fact}.",
Mood.CALM: "Я уже сказал: {fact}.",
Mood.AGGRESSIVE: "Я ТРЕТИЙ РАЗ ГОВОРЮ — {fact}! Вы слушаете вообще?!",
Mood.CONFUSED: "Так я ж говорил... {fact}...",
}
class TemplateCaller:
def __init__(self) -> None:
self._turn = 0
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
self._turn += 1
if turn.repeated:
for _ in turn.repeated:
persona.on_repeat()
mood = persona.remember()
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
parts: list[str] = []
for fact_id in turn.revealed:
parts.append(REVEAL[mood].format(fact=facts[fact_id]))
for fact_id in turn.repeated:
parts.append(REPEAT[mood].format(fact=facts[fact_id]))
if not parts:
options = FILLERS[mood]
parts.append(options[(self._turn - 1) % len(options)])
return CallerLine(text=" ".join(_sentence_case(part) for part in parts), mood=mood)
def _sentence_case(text: str) -> str:
"""Факт в сценарии записан как фрагмент («улица Ленина, 14»), а в начале
реплики должен звучать как начало фразы."""
return text[:1].upper() + text[1:] if text else text

View file

@ -0,0 +1,72 @@
"""Эмбеддинги для матчинга вопроса оператора к чек-листу.
Не regex: «на каком этаже?», «этаж какой?», «а этаж» пришлось бы перечислять
руками для каждого факта каждого сценария, а сценарии пишут методист и генератор
(docs/arch/STACK.md). Модель — multilingual-e5-small в ONNX: русский, CPU,
около 10 мс, и не отнимает процессор у распознавания речи.
"""
from pathlib import Path
from typing import Protocol
import numpy as np
class Embedder(Protocol):
def embed(self, texts: list[str]) -> np.ndarray:
"""Матрица нормированных векторов, строка на текст."""
...
def normalize(vectors: np.ndarray) -> np.ndarray:
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
return vectors / np.clip(norms, 1e-12, None)
class E5Embedder:
"""multilingual-e5-small, квантованный ONNX.
Для симметричной задачи «вопрос ↔ вопрос» e5 требует префикс `query: `
с обеих сторон — без него близость фраз заметно проседает.
"""
PREFIX = "query: "
MAX_TOKENS = 128 # реплика оператора — одна-две фразы, длиннее не бывает
def __init__(self, model_dir: Path) -> None:
import onnxruntime as ort
from tokenizers import Tokenizer
model_path = model_dir / "model_quantized.onnx"
tokenizer_path = model_dir / "tokenizer.json"
if not model_path.exists() or not tokenizer_path.exists():
raise FileNotFoundError(
f"нет модели эмбеддингов в {model_dir} — запусти `make models`"
)
self._tokenizer = Tokenizer.from_file(str(tokenizer_path))
self._tokenizer.enable_truncation(max_length=self.MAX_TOKENS)
self._tokenizer.enable_padding()
options = ort.SessionOptions()
# Два потока: модель делит процессор с распознаванием и синтезом речи.
options.intra_op_num_threads = 2
self._session = ort.InferenceSession(
str(model_path), sess_options=options, providers=["CPUExecutionProvider"]
)
self._inputs = {item.name for item in self._session.get_inputs()}
def embed(self, texts: list[str]) -> np.ndarray:
encoded = self._tokenizer.encode_batch([self.PREFIX + text for text in texts])
input_ids = np.array([item.ids for item in encoded], dtype=np.int64)
attention = np.array([item.attention_mask for item in encoded], dtype=np.int64)
feed = {"input_ids": input_ids, "attention_mask": attention}
if "token_type_ids" in self._inputs:
feed["token_type_ids"] = np.zeros_like(input_ids)
hidden = self._session.run(None, feed)[0]
# Среднее по токенам без паддинга — так e5 обучалась.
mask = attention[..., None].astype(hidden.dtype)
pooled = (hidden * mask).sum(axis=1) / np.clip(mask.sum(axis=1), 1e-9, None)
return normalize(pooled)

View file

@ -0,0 +1,77 @@
"""Состояние звонящего: базовый профиль, эмоциональная дуга, раздражение.
Дуга — фон, а не пьеса: она меняет подачу, а не факты. Директивы преподавателя
перекрывают дугу на время действия, повторные вопросы копят раздражение
(docs/product/CALL-SIM.md).
"""
from dataclasses import dataclass, field
from app.domain.events import Mood
from app.scenarios.schema import Persona
#: Настроение базового профиля, если дуга не задана.
BASE_MOOD: dict[str, Mood] = {
"calm": Mood.CALM,
"panic": Mood.PANIC,
"aggressive": Mood.AGGRESSIVE,
"elderly": Mood.CONFUSED,
"drunk": Mood.CONFUSED,
"evasive": Mood.WORRIED,
"child": Mood.PANIC,
"foreigner": Mood.WORRIED,
}
#: С какого числа повторов звонящий срывается в агрессию.
#: Первый повтор — «Я же сказал!», второй и дальше — уже крик.
AGGRESSION_AFTER_REPEATS = 2
#: Директивы, меняющие подачу. Остальные (обрыв связи, второй пострадавший)
#: правят факты и таймеры, а не настроение.
DIRECTIVE_MOOD: dict[str, Mood] = {
"panic_rises": Mood.PANIC,
"screaming": Mood.PANIC,
"turns_aggressive": Mood.AGGRESSIVE,
"distracted": Mood.CONFUSED,
}
@dataclass
class PersonaState:
persona: Persona
stage: str = "registration"
repeats: int = 0
directive: str | None = None
history: list[Mood] = field(default_factory=list)
@property
def base(self) -> str:
return self.persona.base
def _arc_mood(self) -> Mood:
for step in self.persona.arc:
if step.stage == self.stage:
return step.mood
return BASE_MOOD.get(self.persona.base, Mood.CALM)
@property
def mood(self) -> Mood:
"""Приоритет: директива преподавателя → раздражение → дуга → база."""
if self.directive in DIRECTIVE_MOOD:
return DIRECTIVE_MOOD[self.directive]
if self.repeats >= AGGRESSION_AFTER_REPEATS:
return Mood.AGGRESSIVE
return self._arc_mood()
def on_repeat(self) -> None:
"""Повтор не штрафуется в оценке (иногда он оправдан), но звонящий
его запоминает — и это попадает в разбор."""
self.repeats += 1
def advance(self, stage: str) -> None:
self.stage = stage
def remember(self) -> Mood:
mood = self.mood
self.history.append(mood)
return mood

View file

@ -0,0 +1,28 @@
"""Модели диалога, общие для всех сессий процесса.
Эмбеддер грузится один раз при старте приложения: 2–3 секунды на загрузке
занятия недопустимы, а на старте стенда их никто не заметит.
Если модели нет — занятие всё равно идёт, но подсказки теряют точность
(идут по порядку чек-листа), и это видно в /api/health.
"""
import logging
from functools import lru_cache
from pathlib import Path
from app.config import get_settings
from app.dialog.embeddings import E5Embedder, Embedder
log = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[2]
@lru_cache(maxsize=1)
def get_embedder() -> Embedder | None:
model_dir = ROOT / get_settings().models_dir / "e5-small"
try:
return E5Embedder(model_dir)
except FileNotFoundError:
log.warning("модели эмбеддингов нет в %s — слот-автомат выключен, make models", model_dir)
return None

185
backend/app/dialog/slots.py Normal file
View file

@ -0,0 +1,185 @@
"""Слот-автомат: какие факты раскрыты, какие вопросы заданы.
**Звонящий не выдаёт информацию сам.** Автомат держит состояние явно и отдаёт
звонящему только раскрытые факты — иначе LLM услужливо назовёт адрес без вопроса
(docs/product/CALL-SIM.md).
Тот же автомат детерминированно отвечает на вопрос «спросил ли оператор про этаж»:
на нём стоят подсказки, полнота опроса в оценке (E1) и аналитика группы.
"""
import re
from dataclasses import dataclass, field
import numpy as np
from app.dialog.embeddings import Embedder
from app.scenarios.schema import ChecklistItem, Fact, Scenario
#: Как решается, что реплика — вопрос пункта чек-листа.
#:
#: Порог по косинусной близости не работает: на multilingual-e5-small настоящие
#: вопросы дают 0.79–0.95, а не-вопросы вроде «Оставайтесь на линии» — до 0.89.
#: Диапазоны перекрываются, и любой порог либо выдаёт адрес на «успокойтесь»,
#: либо не слышит «где вы находитесь?».
#:
#: Работает сравнение с ближайшим соседом: реплика засчитывается пункту, если
#: она ближе к одной из его формулировок, чем к любому не-вопросу из
#: checklists/common.yaml. На отложенных фразах (tests/test_slots_e5.py):
#: 19 из 20 вопросов распознано, 0 из 8 ложных срабатываний.
#:
#: MATCH_MARGIN — насколько ближе к пункту, чем к не-вопросу. Ноль даёт лучшее
#: распознавание при нуле ложных срабатываний; 0.03 теряет уже 5 вопросов из 20.
#: MATCH_FLOOR — нижняя граница на случай сценария без не-вопросов.
MATCH_MARGIN = 0.0
MATCH_FLOOR = 0.75
#: Реплика режется только по границам предложений, и знак остаётся при части.
#: «?» для e5 — сильный признак вопроса: без него «Куда ехать» ближе
#: к «Оставайтесь на линии», чем к пункту про адрес. По запятым и «и» не режем:
#: «Улица, дом?» распадается на обрывки, которые не похожи ни на что.
_SENTENCES = re.compile(r"(?<=[?!.;])\s+")
@dataclass
class TurnResult:
"""Что произошло в одной реплике оператора."""
text: str
matched: list[str] = field(default_factory=list) # пункты чек-листа
revealed: list[str] = field(default_factory=list) # факты, раскрытые впервые
repeated: list[str] = field(default_factory=list) # факты, спрошенные повторно
scores: dict[str, float] = field(default_factory=dict)
@property
def understood(self) -> bool:
return bool(self.matched)
class SlotMachine:
def __init__(
self,
scenario: Scenario,
embedder: Embedder,
floor: float = MATCH_FLOOR,
margin: float = MATCH_MARGIN,
) -> None:
self.scenario = scenario
self.floor = floor
self.margin = margin
self._embedder = embedder
self._facts: dict[str, Fact] = {fact.id: fact for fact in scenario.facts}
self._items: list[ChecklistItem] = [item for item in scenario.checklist if item.question]
# Каждая формулировка — отдельная строка; `_owner` помнит, чей это пункт.
texts: list[str] = []
self._owner: list[int] = []
for index, item in enumerate(self._items):
for text in [item.question, *item.examples]:
texts.append(text)
self._owner.append(index)
self._anchors = embedder.embed(texts)
self._not_questions = (
embedder.embed(scenario.not_questions) if scenario.not_questions else None
)
# Какой пункт чек-листа какие факты раскрывает. Скрытые факты вопросом
# не раскрываются никогда — только подходом.
self._reveals: dict[str, list[str]] = {}
for item in self._items:
if item.fact and not self._facts[item.fact].hidden:
self._reveals.setdefault(item.id, []).append(item.fact)
for fact in scenario.facts:
question = fact.reveal_on.question if fact.reveal_on else None
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
self._reveals.setdefault(question, []).append(fact.id)
self.asked: list[str] = []
self.revealed: list[str] = []
# ── реплика оператора ──
def _clauses(self, text: str) -> list[str]:
parts = [part.strip() for part in _SENTENCES.split(text.strip()) if part.strip()]
return parts or [text]
def hear(self, text: str) -> TurnResult:
"""Сопоставить реплику с чек-листом и раскрыть заслуженные факты.
Реплика режется на предложения: «Где вы? Есть кто внутри?» — два вопроса,
и оба должны засчитаться.
"""
result = TurnResult(text=text)
clauses = self._embedder.embed(self._clauses(text))
by_anchor = self._anchors @ clauses.T # формулировки × части реплики
# Лучшая близость каждого пункта к каждой части реплики.
owner = np.array(self._owner)
item_scores = np.stack(
[by_anchor[owner == index].max(axis=0) for index in range(len(self._items))]
) # пункты × части
# Насколько каждая часть похожа на не-вопрос.
if self._not_questions is not None:
not_question = (self._not_questions @ clauses.T).max(axis=0)
else:
not_question = np.full(clauses.shape[0], -1.0)
# Часть реплики засчитывается только одному, ближайшему пункту:
# «адрес» не должен заодно раскрыть «кто в квартире».
winners: dict[int, float] = {}
for part in range(clauses.shape[0]):
best = int(np.argmax(item_scores[:, part]))
score = float(item_scores[best, part])
if score >= self.floor and score > not_question[part] + self.margin:
winners[best] = max(score, winners.get(best, -1.0))
for index, item in enumerate(self._items):
result.scores[item.id] = float(item_scores[index].max())
if index not in winners:
continue
result.matched.append(item.id)
if item.id not in self.asked:
self.asked.append(item.id)
for fact_id in self._reveals.get(item.id, []):
if fact_id in self.revealed:
if fact_id not in result.repeated:
result.repeated.append(fact_id)
else:
self.revealed.append(fact_id)
result.revealed.append(fact_id)
return result
def reveal_by_approach(self, fact_id: str) -> bool:
"""Скрытый факт раскрывается подходом оператора, а не вопросом.
Решение «создал ли оператор подход» принимает LLM (temperature=0) —
автомат только фиксирует результат."""
fact = self._facts.get(fact_id)
if fact is None or fact_id in self.revealed:
return False
self.revealed.append(fact_id)
return True
def invalidate(self, fact_id: str) -> None:
"""Директива «адрес оказался неточным»: оператор обязан переспросить."""
if fact_id in self.revealed:
self.revealed.remove(fact_id)
# ── что видят другие ──
def revealed_facts(self) -> list[Fact]:
"""Единственное, что уходит в контекст звонящего."""
return [self._facts[fact_id] for fact_id in self.revealed]
def unasked(self) -> list[ChecklistItem]:
"""Неотработанные пункты по порядку чек-листа — источник подсказки."""
return [item for item in self._items if item.id not in self.asked]
def missing_required(self) -> list[str]:
"""Обязательные факты, которые оператор так и не добыл — основание E1."""
return [
fact_id
for fact_id in self.scenario.ground_truth.required_facts
if fact_id not in self.revealed
]

View file

@ -13,6 +13,7 @@ from app.api.ws import control as control_ws
from app.api.ws import observe as observe_ws
from app.config import get_settings
from app.db.base import get_sessionmaker
from app.dialog.runtime import get_embedder
from app.scenarios import store
from app.session.hub import hub
from app.session.journal import DbJournal
@ -35,7 +36,10 @@ async def lifespan(app: FastAPI):
# Журнал: всё, что не записано, для оценки не существует.
hub.journal = DbJournal(get_sessionmaker())
# Прогрев моделей — карточка lct-06.
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
app.state.embeddings_ready = get_embedder() is not None
# Прогрев моделей речи — карточка lct-06.
app.state.models_ready = False
yield
@ -58,5 +62,6 @@ async def health() -> dict:
"status": "ok",
"models_ready": getattr(app.state, "models_ready", False),
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
"offline": settings.offline,
}

View file

@ -56,8 +56,19 @@ def _derive_ground_truth(scenario: Scenario) -> Scenario:
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 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"} & set(declared)

View file

@ -60,9 +60,17 @@ class Fact(Strict):
class ChecklistItem(Strict):
"""Пункт эталонного опроса.
`examples` — другие формулировки того же вопроса. Без них матчинг
по эмбеддингам не отличает вопрос от не-вопроса: на e5 «Оставайтесь
на линии» ближе к пункту чек-листа, чем половина настоящих вопросов.
"""
id: str
question: str | None = None
fact: str | None = None
examples: list[str] = []
class EraGlonass(Strict):
@ -109,6 +117,10 @@ class Scenario(Strict):
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()

View file

@ -18,6 +18,8 @@ from app.domain.events import (
Speaker,
TranscriptEntry,
)
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.domain.kio import KIO, apply_patch
from app.session.timers import SessionTimers
@ -44,6 +46,11 @@ class SessionState:
hints_shown: list[str] = field(default_factory=list)
directives: list[str] = field(default_factory=list)
# Звонящий. Автомата нет, если не скачана модель эмбеддингов:
# занятие идёт, подсказки откатываются на порядок чек-листа.
slots: SlotMachine | None = None
persona: PersonaState | None = None
started_at: datetime | None = None
ended_at: datetime | None = None
end_reason: CallEndReason | None = None

View file

@ -14,15 +14,18 @@ dependencies = [
"httpx>=0.27",
"pyyaml>=6.0",
"structlog>=24.1",
# Эмбеддинги для слот-автомата: матчинг вопроса к чек-листу. Нужны ядру,
# а не только голосу, поэтому не в группе voice.
"onnxruntime>=1.18",
"tokenizers>=0.19",
"numpy>=1.26",
]
[project.optional-dependencies]
# Тяжёлый слой моделей — ставится отдельно: uv sync --extra voice
voice = [
"onnx-asr>=0.6",
"onnxruntime>=1.18",
"torch>=2.2",
"numpy>=1.26",
]
dev = [
"pytest>=8.2",

50
backend/scripts/models.py Normal file
View file

@ -0,0 +1,50 @@
"""make models: веса моделей в backend/models/.
Сейчас качает эмбеддинги для слот-автомата. Распознавание (GigaAM) и синтез
(Silero) добавит карточка lct-02 — после замера на демо-машине.
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
"""
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODELS = ROOT / "models"
E5 = "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/"
FILES = {
"e5-small/config.json": E5 + "config.json",
"e5-small/tokenizer.json": E5 + "tokenizer.json",
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
}
def fetch(url: str, target: Path) -> None:
partial = target.with_suffix(target.suffix + ".part")
offset = partial.stat().st_size if partial.exists() else 0
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
total = offset + int(response.headers.get("Content-Length", 0))
while chunk := response.read(1 << 20):
out.write(chunk)
done = out.tell()
if total:
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
partial.rename(target)
print()
def main() -> None:
for relative, url in FILES.items():
target = MODELS / relative
if target.exists():
print(f" {relative}: уже есть")
continue
target.parent.mkdir(parents=True, exist_ok=True)
fetch(url, target)
print("эмбеддинги готовы; GigaAM и Silero — карточка tasks/lct-02-latency-baseline.md")
if __name__ == "__main__":
sys.exit(main())

94
backend/scripts/repl.py Normal file
View file

@ -0,0 +1,94 @@
"""make repl: текстовый диалог со звонящим, без голоса и без LLM.
Проверяет главную механику до сборки голосового контура: звонящий не выдаёт
данные сам, раскрывает факт на правильный вопрос и злится на повторы.
make repl сценарий по умолчанию
make repl s=fire-apartment-l2 конкретный сценарий
Команды: /подсказка, /факты, /итог, /выход
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402
from app.dialog.caller import TemplateCaller # noqa: E402
from app.dialog.embeddings import E5Embedder # noqa: E402
from app.dialog.persona import PersonaState # noqa: E402
from app.dialog.slots import SlotMachine # noqa: E402
from app.scenarios.loader import ScenarioError, load_library # noqa: E402
LIBRARY = ROOT.parent / "scenarios"
def summary(slots: SlotMachine) -> None:
required = slots.scenario.ground_truth.required_facts
got = [fact_id for fact_id in required if fact_id in slots.revealed]
print(f"\n добыто обязательных фактов: {len(got)} из {len(required)}")
for fact_id in slots.missing_required():
item = next((i for i in slots.scenario.checklist if i.fact == fact_id), None)
hint = f" — эталонный вопрос: «{item.question}»" if item else ""
print(f" E1 не добыт {fact_id}{hint}")
def main() -> None:
scenario_id = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else None
try:
library = {scenario.id: scenario for scenario in load_library(LIBRARY)}
except ScenarioError as exc:
raise SystemExit(f"библиотека не прошла проверку: {exc}") from exc
scenario = library.get(scenario_id) if scenario_id else next(iter(library.values()))
if scenario is None:
raise SystemExit(f"нет сценария {scenario_id}; есть: {', '.join(library)}")
try:
embedder = E5Embedder(ROOT / get_settings().models_dir / "e5-small")
except FileNotFoundError as exc:
raise SystemExit(str(exc)) from exc
slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona)
caller = TemplateCaller()
print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
print(f"ЗВОНЯЩИЙ [{persona.mood.value}]: {scenario.first_line}")
while True:
try:
line = input("ОПЕРАТОР: ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line == "/выход":
break
if line == "/итог":
summary(slots)
continue
if line == "/факты":
for fact in slots.revealed_facts():
print(f" раскрыт {fact.id}: {fact.value}")
continue
if line == "/подсказка":
unasked = slots.unasked()
print(f" подсказка: {unasked[0].question}" if unasked else " все пункты чек-листа отработаны")
continue
turn = slots.hear(line)
reply = caller.reply(turn, persona, slots)
if turn.matched:
print(f" [понято: {', '.join(turn.matched)}]")
print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}")
summary(slots)
if __name__ == "__main__":
main()

182
backend/tests/test_slots.py Normal file
View file

@ -0,0 +1,182 @@
"""Слот-автомат и звонящий без LLM.
Логика проверяется на детерминированной заглушке эмбеддингов: тесты не зависят
от скачанной модели. Качество матчинга на настоящей e5 — в test_slots_e5.py.
"""
import re
import zlib
import numpy as np
import pytest
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.domain.events import Mood
from app.scenarios.schema import Scenario
class StemEmbedder:
"""Мешок основ слов: пять первых букв, хеш в вектор. Грубо, но
детерминированно — «этаже» и «этаж» совпадают, «пожар» и «адрес» нет.
Служебные слова выброшены: без этого «Повторите адрес» и «Какой адрес
и этаж» расходятся на вопросительных словах, а не сходятся на адресе.
"""
DIM = 512
STOP = {"что", "как", "какой", "какая", "есть", "вас", "это", "ещё", "раз", "повто", "уточн"}
def embed(self, texts):
rows = []
for text in texts:
vector = np.zeros(self.DIM)
for word in re.findall(r"\w+", text.lower()):
stem = word[:5]
if len(word) > 2 and word not in self.STOP and stem not in self.STOP:
vector[zlib.crc32(stem.encode()) % self.DIM] += 1.0
norm = np.linalg.norm(vector)
rows.append(vector / norm if norm else vector)
return np.array(rows)
SCENARIO = Scenario.model_validate(
{
"id": "slots-test",
"title": "Проверка автомата",
"type": "fire",
"level": "L2",
"persona": {"base": "panic", "arc": [{"stage": "registration", "mood": "panic"}]},
"first_line": "Алло! Горим!",
"facts": [
{"id": "f_address", "value": "улица Ленина, 14, 5-й этаж", "reveal_on": {"question": "q_address"}},
{"id": "f_people", "value": "в квартире жена и ребёнок", "reveal_on": {"question": "q_people"}},
{"id": "f_burns", "value": "горит балкон", "reveal_on": {"question": "q_burns"}},
{
"id": "f_secret",
"value": "муж курил на балконе",
"hidden": True,
"reveal_on": {"approach": "объяснил, что вину никто не ищет"},
},
],
"checklist": [
{"id": "q_address", "question": "Какой адрес и этаж", "fact": "f_address"},
{"id": "q_people", "question": "Есть ли люди в квартире", "fact": "f_people"},
{"id": "q_burns", "question": "Что именно горит", "fact": "f_burns"},
{"id": "q_cause", "question": "Из-за чего начался пожар", "fact": "f_secret"},
],
"ground_truth": {"required_facts": ["f_address", "f_people", "f_burns"]},
}
)
@pytest.fixture
def slots():
return SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
def test_nothing_is_revealed_without_a_question(slots):
turn = slots.hear("Служба 112, что у вас случилось?")
assert turn.revealed == []
assert slots.revealed_facts() == []
def test_question_reveals_its_fact(slots):
turn = slots.hear("Назовите адрес и этаж")
assert turn.revealed == ["f_address"]
assert "q_address" in slots.asked
def test_repeat_is_detected_not_revealed_again(slots):
slots.hear("Какой адрес?")
turn = slots.hear("Повторите адрес")
assert turn.revealed == []
assert turn.repeated == ["f_address"], "повтор не распознан — звонящий не разозлится"
def test_two_questions_in_one_line_both_count(slots):
turn = slots.hear("Какой адрес? Есть ли люди в квартире?")
assert set(turn.revealed) == {"f_address", "f_people"}
def test_hidden_fact_is_not_given_for_a_direct_question(slots):
"""Скрывающий звонящий уклоняется от прямого вопроса: факт раскрывается
только подходом, иначе механика L3 превращается в обычный чек-лист."""
turn = slots.hear("Из-за чего начался пожар?")
assert "f_secret" not in turn.revealed
assert "q_cause" in slots.asked, "вопрос задан — это должно быть видно в разборе"
assert slots.reveal_by_approach("f_secret")
assert "f_secret" in [fact.id for fact in slots.revealed_facts()]
def test_unasked_shrinks_in_checklist_order(slots):
assert [item.id for item in slots.unasked()][:2] == ["q_address", "q_people"]
slots.hear("Какой адрес?")
assert slots.unasked()[0].id == "q_people", "подсказка должна вести к следующему пункту"
def test_missing_required_is_the_basis_for_e1(slots):
slots.hear("Какой адрес?")
assert slots.missing_required() == ["f_people", "f_burns"]
def test_invalidated_fact_must_be_asked_again(slots):
"""Директива «адрес оказался неточным»."""
slots.hear("Какой адрес?")
slots.invalidate("f_address")
assert slots.hear("Уточните адрес").revealed == ["f_address"]
def test_caller_never_speaks_an_unrevealed_fact(slots):
"""Ключевое свойство продукта: звонящий не выдаёт данные сам.
Проверяется по всем фактам на длинной серии реплик, включая мимо чек-листа."""
caller = TemplateCaller()
persona = PersonaState(SCENARIO.persona)
lines = [
"Служба 112, слушаю",
"Успокойтесь, пожалуйста",
"Что случилось?",
"Какой адрес?",
"Вы в безопасности?",
"Говорите громче",
"Есть ли люди в квартире?",
"Из-за чего начался пожар?",
]
for line in lines:
turn = slots.hear(line)
reply = caller.reply(turn, persona, slots).text
revealed = {fact.id for fact in slots.revealed_facts()}
for fact in SCENARIO.facts:
if fact.id not in revealed:
assert fact.value not in reply, f"звонящий выдал «{fact.value}» без вопроса на «{line}»"
def test_repeats_push_the_caller_into_aggression(slots):
caller = TemplateCaller()
persona = PersonaState(SCENARIO.persona)
first = caller.reply(slots.hear("Какой адрес?"), persona, slots)
assert first.mood is Mood.PANIC
caller.reply(slots.hear("Адрес какой?"), persona, slots)
third = caller.reply(slots.hear("Ещё раз адрес"), persona, slots)
assert third.mood is Mood.AGGRESSIVE, "настойчивый повтор должен сдвигать к агрессии"
assert "Ленина" in third.text, "в раздражении звонящий всё равно повторяет факт"
def test_directive_overrides_the_arc():
persona = PersonaState(SCENARIO.persona)
persona.directive = "turns_aggressive"
assert persona.mood is Mood.AGGRESSIVE
def test_same_lines_give_same_replies():
"""Сценарий занятия должен звучать одинаково у каждой группы."""
def run():
machine = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
caller, persona = TemplateCaller(), PersonaState(SCENARIO.persona)
return [caller.reply(machine.hear(line), persona, machine).text for line in ("Алло", "Какой адрес?", "Что?")]
assert run() == run()

View file

@ -0,0 +1,96 @@
"""Качество матчинга на настоящей модели multilingual-e5-small.
Фразы здесь **отложенные**: ни одна не совпадает с формулировками из
scenarios/checklists/fire.yaml и не-вопросами из common.yaml. Иначе тест
проверял бы, что модель узнаёт собственные образцы, а не живую речь.
Ошибки несимметричны, и тест это отражает:
* ложное срабатывание — звонящий выдал адрес на «успокойтесь», главная
механика продукта сломана. Допускается ноль;
* промах — звонящий переспросил «что?!», оператор перефразирует.
Для паникующего это правдоподобно, допускается немного.
Без скачанной модели тест пропускается: `make models`.
"""
from pathlib import Path
import pytest
from app.dialog.slots import SlotMachine
from app.scenarios.loader import load_file
MODEL = Path(__file__).resolve().parents[1] / "models" / "e5-small"
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
pytestmark = pytest.mark.skipif(
not (MODEL / "model_quantized.onnx").exists(), reason="нет модели эмбеддингов — make models"
)
QUESTIONS = {
"q_address": ["Назовите адрес", "Улица, дом?", "Этаж какой?", "А этаж", "Куда ехать?", "Скажите точный адрес"],
"q_what_burns": ["Что у вас горит?", "Горит квартира или балкон?", "Что загорелось?"],
"q_people": ["Люди в квартире есть?", "Дети внутри есть?", "Кто-нибудь ещё в квартире?"],
"q_smoke": ["В подъезде дым?", "Дым сильный?", "Задымление есть?"],
"q_gas": ["Газ перекрыть можете?", "Плита газовая?"],
"q_caller": ["Представьтесь, пожалуйста", "Как к вам обращаться?", "Оставьте номер для связи"],
}
NOT_QUESTIONS = [
"Служба 112, слушаю",
"Я вас слышу",
"Бригада уже выехала",
"Говорите медленнее",
"Понял вас",
"Закройте дверь",
"Сохраняйте спокойствие",
"Всё будет хорошо",
]
#: Сколько вопросов из отложенного набора обязано распознаваться.
MIN_RECALL = 0.9
@pytest.fixture(scope="module")
def slots():
from app.dialog.embeddings import E5Embedder
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
return SlotMachine(scenario, E5Embedder(MODEL))
def test_no_answer_to_what_was_not_asked(slots):
"""Ноль ложных срабатываний: иначе звонящий выдаёт данные сам."""
false_hits = {
phrase: slots.hear(phrase).matched for phrase in NOT_QUESTIONS if slots.hear(phrase).matched
}
assert not false_hits, f"звонящий «услышал» вопрос там, где его не было: {false_hits}"
def test_real_questions_are_understood(slots):
total = sum(len(phrases) for phrases in QUESTIONS.values())
misses = []
for expected, phrases in QUESTIONS.items():
for phrase in phrases:
matched = slots.hear(phrase).matched
if matched != [expected]:
misses.append(f"«{phrase}» → {matched or 'не понято'}, ждали {expected}")
recall = (total - len(misses)) / total
assert recall >= MIN_RECALL, (
f"распознано {total - len(misses)} из {total} ({recall:.0%}), нужно {MIN_RECALL:.0%}:\n "
+ "\n ".join(misses)
)
def test_one_phrase_is_fast_enough(slots):
"""Бюджет из docs/arch/STACK.md — около 10 мс: модель делит процессор
с распознаванием и синтезом речи."""
import time
slots.hear("разогрев")
started = time.monotonic()
for _ in range(10):
slots.hear("На каком этаже пожар?")
per_phrase_ms = (time.monotonic() - started) * 100
assert per_phrase_ms < 50, f"{per_phrase_ms:.0f} мс на реплику"

85
backend/uv.lock generated
View file

@ -228,6 +228,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hf-xet"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" },
{ url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" },
{ url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" },
{ url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" },
{ url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" },
{ url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" },
{ url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@ -271,6 +287,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "huggingface-hub"
version = "1.32.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "httpx" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/0f/e83fdd856da8fca26bf78d71709ebd120432a0ce535e72b9597cab1eb5bf/huggingface_hub-1.32.0.tar.gz", hash = "sha256:ed70a45498abe86039df7c2f4e5f7575de524be908d3840e8f828d5525eafd6a", size = 1038662, upload-time = "2026-09-17T10:27:48.049Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/cf/d98dd561d6d0d7b7d7a64d1563f8aaaa7c235daee41c1c9bcc3da62420ed/huggingface_hub-1.32.0-py3-none-any.whl", hash = "sha256:b0c7c80561969d9cdacdd55fce67ba9584cca0b9d4ea80957a3a5c1445fac5c8", size = 842906, upload-time = "2026-09-17T10:27:46.102Z" },
]
[[package]]
name = "idna"
version = "3.19"
@ -310,11 +346,14 @@ dependencies = [
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "numpy" },
{ name = "onnxruntime" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "structlog" },
{ name = "tokenizers" },
{ name = "uvicorn", extra = ["standard"] },
]
@ -324,9 +363,7 @@ dev = [
{ name = "pytest-asyncio" },
]
voice = [
{ name = "numpy" },
{ name = "onnx-asr" },
{ name = "onnxruntime" },
{ name = "torch" },
]
@ -336,9 +373,9 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.29" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.27" },
{ name = "numpy", marker = "extra == 'voice'", specifier = ">=1.26" },
{ name = "numpy", specifier = ">=1.26" },
{ name = "onnx-asr", marker = "extra == 'voice'", specifier = ">=0.6" },
{ name = "onnxruntime", marker = "extra == 'voice'", specifier = ">=1.18" },
{ name = "onnxruntime", specifier = ">=1.18" },
{ name = "pydantic", specifier = ">=2.7" },
{ name = "pydantic-settings", specifier = ">=2.3" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.2" },
@ -346,6 +383,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "structlog", specifier = ">=24.1" },
{ name = "tokenizers", specifier = ">=0.19" },
{ name = "torch", marker = "extra == 'voice'", specifier = ">=2.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
]
@ -840,6 +878,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tokenizers"
version = "0.23.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/1e/bc6587c5ab643b2e17776cace9070a2ae73549c86bffac9934a600bf3c31/tokenizers-0.23.2.tar.gz", hash = "sha256:7f0f085686b9de0d0079e6f874ae053600db64c5d13049e0bbc0119926d25aac", size = 385745, upload-time = "2026-09-03T08:55:42.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ed/8a443528baa6fac8dfe8c3b75b038c63ac92bb539bcabe311e227c718173/tokenizers-0.23.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:85a9a357a3764aecc904ee76bdaf8cf1ad8e5a67a1b929a487c4a39b49ed0e90", size = 3148852, upload-time = "2026-09-03T08:55:30.874Z" },
{ url = "https://files.pythonhosted.org/packages/67/49/22da045a91732384d3a3771816bf188dc5a1f702c32e635afa7c679c0bef/tokenizers-0.23.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:986670e43691469dcee610ea0f846f91a8f84e91fc6f7a48d4c064414c0ec2bf", size = 3101593, upload-time = "2026-09-03T08:55:28.587Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4d/8f569ed49372a3ed8e57099bd515055fd48d7c95912c4307cda6973c2168/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a37039b5dfc4af84eb3ef0a92f4307e28936c8f9adccba2629d36f652e9bf7a2", size = 3516830, upload-time = "2026-09-03T08:55:14.741Z" },
{ url = "https://files.pythonhosted.org/packages/2a/de/e2f14c8919d5bf51874051d00d6c7b7e0e8bde6c6a2dbeddda7f642896ff/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b7e37ba198f24150f523e1242e83c4970de4a525480586be5dcc24d9add32c5", size = 3407975, upload-time = "2026-09-03T08:55:16.842Z" },
{ url = "https://files.pythonhosted.org/packages/c5/bd/93c69152d02ef06ce47aed8b2bf4952dcf733c935a62791873932b2934d9/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43e4f2071e3cc8d5d86421c874aebc82659bb51a68bcdef5a0da75ee89511ccb", size = 3748165, upload-time = "2026-09-03T08:55:24.769Z" },
{ url = "https://files.pythonhosted.org/packages/2d/b7/56b84b80bc96942bba8eb23751a9e8a1fce4faaf4390425e7083f721c98c/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325fee2e0418a9dc6c9ecf736a5f5f0db7875183ace9549ae339da76f7a1fbb7", size = 4024165, upload-time = "2026-09-03T08:55:18.806Z" },
{ url = "https://files.pythonhosted.org/packages/9b/8a/0175e216f005c2fe08238292663aa41e4c802b216e71047a69a0e9fc6fa3/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:950d7c9426fa72406a0ffeacdbc0bb9985f5db20eb8b263f29c79aaf83105703", size = 3591899, upload-time = "2026-09-03T08:55:22.752Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ca/ca6b93c7820df123b2662a9469e8facc826ccc94e98fdd0d615f6431e73a/tokenizers-0.23.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305", size = 3386843, upload-time = "2026-09-03T08:55:26.584Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a4/4f9106d317b14a80aefea9f0e3a8d07ef25f856a7607eb7f5ab894281fcb/tokenizers-0.23.2-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:12f0835dc2ee694746a76adf7b1567d4346a4a502ebe93fb1f5f80ea49799b78", size = 3577314, upload-time = "2026-09-03T08:55:20.825Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6a/1552b70fb0d9ab074fd3fc961435d01364e79c9058481822c3af6e8d402c/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eb2f9c8a24da020ea8c11a01a19c1c2547912d92121ae4a01cfbca46125dee40", size = 9967367, upload-time = "2026-09-03T08:55:33.188Z" },
{ url = "https://files.pythonhosted.org/packages/06/01/3ccb3a956c7528b2507b8a9714155c4baf86af593039db6ea375dd0c96c3/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f486f402f6f9abee5bb032553736813af0c710a86b2e0ca592634c55cea1f835", size = 9811886, upload-time = "2026-09-03T08:55:35.642Z" },
{ url = "https://files.pythonhosted.org/packages/fa/73/7038e612d48bda1599457f712f6bd3854eae1a9dc9c13aa47f835349db48/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:bef235815a067b2648caf6dcc7a71091b0b0fff9ee8057f6451eb9335fae52ef", size = 10146224, upload-time = "2026-09-03T08:55:38.391Z" },
{ url = "https://files.pythonhosted.org/packages/b5/d8/8e9e4e0b287a338d8f88976729628c9d22e8a54cfaf9777018a7f7cb58a0/tokenizers-0.23.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5c56bda1511921587789163e524d196ed8284174ac23abd7685d5ea8da6c4718", size = 10256304, upload-time = "2026-09-03T08:55:40.977Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1f/c79a01f671a49728ebb0b61f7ff9ea45663b66cab40bc0858e9859b25c16/tokenizers-0.23.2-cp310-abi3-win32.whl", hash = "sha256:debf978920d93ba9c219bd67cc4bbfaf912c9039e41e7a28b91ec15e3728c95a", size = 2592809, upload-time = "2026-09-03T08:55:48.02Z" },
{ url = "https://files.pythonhosted.org/packages/db/f7/0a69ac6b82dbccf3f71add938a161c497952749294b8dd6dfe03a819dc40/tokenizers-0.23.2-cp310-abi3-win_amd64.whl", hash = "sha256:2e96f5699d5249c9c64aa8412e044f727aae3a4098cf830f9901ec1afc361cde", size = 2863236, upload-time = "2026-09-03T08:55:46.193Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/dee84cb44175be1b4c35bd2f770727494e78f0bb38e571a623ade94dbebb/tokenizers-0.23.2-cp310-abi3-win_arm64.whl", hash = "sha256:e49c394456dd9985787fec76132438ba3fb8911f857b1bf3d40119f9292d41aa", size = 2729352, upload-time = "2026-09-03T08:55:44.345Z" },
]
[[package]]
name = "torch"
version = "2.14.0"
@ -867,6 +932,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/5b/ed9d177f6a133389d5fa89e298f3e2a879b62952aa928ac6ddd04467f225/torch-2.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:6981872df75eb5409c439050d36b67cb57d88b4e11080516e2aa7410f6730705", size = 124096356, upload-time = "2026-09-02T13:43:02.941Z" },
]
[[package]]
name = "tqdm"
version = "4.70.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
]
[[package]]
name = "triton"
version = "3.8.0"

View file

@ -0,0 +1,19 @@
# Реплики оператора, которые НЕ являются вопросом чек-листа.
#
# Зачем: матчинг по эмбеддингам сравнивает реплику с формулировками вопросов.
# Без этого списка «Оставайтесь на линии» оказывается ближе к пункту чек-листа,
# чем половина настоящих вопросов, — и звонящий выдаёт адрес на «успокойтесь».
# Реплика засчитывается вопросом, только если она ближе к пункту, чем к любой
# фразе отсюда.
#
# Дополнять, когда в разборе видно, что звонящий «понял вопрос», которого не было.
not_questions:
- "Служба 112, что случилось?"
- "Успокойтесь, пожалуйста"
- "Оставайтесь на линии"
- "Помощь уже едет"
- "Я вас понял"
- "Говорите громче, плохо слышно"
- "Выходите из квартиры"
- "Не кладите трубку"
- "Хорошо, записываю"

View file

@ -1,12 +1,28 @@
# Общий чек-лист по классификатору 01 — МЧС.
# Наследуется сценариями через `extends`, дополняется локально.
# Методика: docs/product/METHODOLOGY.md#чек-листы-опроса
#
# `examples` — другие формулировки того же вопроса. Чем их больше и чем они
# разнообразнее, тем точнее звонящий «понимает» оператора. Писать так, как
# говорят живые операторы, а не как в инструкции.
id: fire
dds: "01"
checklist:
- { id: q_address, question: "Назовите адрес: улица, дом, квартира, этаж?" }
- { id: q_what_burns, question: "Что именно горит?" }
- { id: q_people, question: "Есть ли люди в помещении?" }
- { id: q_smoke, question: "Куда идёт дым, есть ли задымление подъезда?" }
- { id: q_gas, question: "Есть ли газ, можно ли перекрыть?" }
- { id: q_caller, question: "Представьтесь и оставьте контактный телефон" }
- id: q_address
question: "Назовите адрес: улица, дом, квартира, этаж?"
examples: ["Какой у вас адрес?", "Где вы находитесь?", "На каком этаже?", "Номер дома и квартиры?"]
- id: q_what_burns
question: "Что именно горит?"
examples: ["Что горит?", "Что именно загорелось?", "Откуда идёт огонь?"]
- id: q_people
question: "Есть ли люди в помещении?"
examples: ["Кто-то остался внутри?", "Все успели выйти?"]
- id: q_smoke
question: "Куда идёт дым, есть ли задымление подъезда?"
examples: ["Куда идёт дым?", "Есть ли задымление в подъезде?", "Сильный дым?"]
- id: q_gas
question: "Есть ли газ, можно ли перекрыть?"
examples: ["У вас газовая плита?", "Перекрыть газ сможете?"]
- id: q_caller
question: "Представьтесь и оставьте контактный телефон"
examples: ["Как вас зовут?", "Ваш номер телефона?"]