From 7c9731093807af01c75541f2e62a9fe394cba346 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 13:57:43 +0300 Subject: [PATCH] =?UTF-8?q?lct-07=20(=D0=B1=D0=B5=D0=B7=20LLM):=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=82-=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82,?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0,=20=D0=B7=D0=B2?= =?UTF-8?q?=D0=BE=D0=BD=D1=8F=D1=89=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BA=D0=B0=D1=85,=20make?= =?UTF-8?q?=20repl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и отдаёт звонящему только их. Тест прогоняет длинную серию реплик и проверяет, что ни один нераскрытый факт не прозвучал. Главная находка — матчинг по порогу близости не работает. На 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 с кэшем. --- Makefile | 8 +- backend/Dockerfile | 3 +- backend/app/api/ws/call.py | 18 +-- backend/app/api/ws/control.py | 7 ++ backend/app/dialog/__init__.py | 0 backend/app/dialog/caller.py | 103 +++++++++++++++++ backend/app/dialog/embeddings.py | 72 ++++++++++++ backend/app/dialog/persona.py | 77 +++++++++++++ backend/app/dialog/runtime.py | 28 +++++ backend/app/dialog/slots.py | 185 +++++++++++++++++++++++++++++++ backend/app/main.py | 7 +- backend/app/scenarios/loader.py | 11 ++ backend/app/scenarios/schema.py | 12 ++ backend/app/session/state.py | 7 ++ backend/pyproject.toml | 7 +- backend/scripts/models.py | 50 +++++++++ backend/scripts/repl.py | 94 ++++++++++++++++ backend/tests/test_slots.py | 182 ++++++++++++++++++++++++++++++ backend/tests/test_slots_e5.py | 96 ++++++++++++++++ backend/uv.lock | 85 +++++++++++++- scenarios/checklists/common.yaml | 19 ++++ scenarios/checklists/fire.yaml | 28 ++++- 22 files changed, 1073 insertions(+), 26 deletions(-) create mode 100644 backend/app/dialog/__init__.py create mode 100644 backend/app/dialog/caller.py create mode 100644 backend/app/dialog/embeddings.py create mode 100644 backend/app/dialog/persona.py create mode 100644 backend/app/dialog/runtime.py create mode 100644 backend/app/dialog/slots.py create mode 100644 backend/scripts/models.py create mode 100644 backend/scripts/repl.py create mode 100644 backend/tests/test_slots.py create mode 100644 backend/tests/test_slots_e5.py create mode 100644 scenarios/checklists/common.yaml diff --git a/Makefile b/Makefile index 21cf451..53acb95 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index 42902fb..c62da5b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py index 94e4699..53a710f 100644 --- a/backend/app/api/ws/call.py +++ b/backend/app/api/ws/call.py @@ -35,16 +35,18 @@ _adapter = TypeAdapter(TraineeToServer) def _next_hint(state) -> tuple[str, str] | None: - """Следующий пункт чек-листа, который ещё не подсказывали. + """Следующий неотработанный пункт чек-листа, который ещё не подсказывали. - Порядок временный: пока нет слот-автомата (lct-07), подсказка идёт - по порядку чек-листа, а не по реально неотработанным пунктам. - Перевод на слот-автомат — карточка lct-14. + «Неотработанный» знает слот-автомат: пункт, о котором оператор уже спросил + своими словами, подсказывать бессмысленно. Без модели эмбеддингов автомата + нет — тогда подсказка идёт по порядку чек-листа. """ - scenario = store.get(state.scenario_id) - if scenario is None: - return None - for item in scenario.checklist: + if state.slots is not None: + candidates = state.slots.unasked() + else: + scenario = store.get(state.scenario_id) + 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 diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 4379e04..c4a651f 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -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) diff --git a/backend/app/dialog/__init__.py b/backend/app/dialog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py new file mode 100644 index 0000000..e914cb3 --- /dev/null +++ b/backend/app/dialog/caller.py @@ -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 diff --git a/backend/app/dialog/embeddings.py b/backend/app/dialog/embeddings.py new file mode 100644 index 0000000..a574939 --- /dev/null +++ b/backend/app/dialog/embeddings.py @@ -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) diff --git a/backend/app/dialog/persona.py b/backend/app/dialog/persona.py new file mode 100644 index 0000000..f4aa322 --- /dev/null +++ b/backend/app/dialog/persona.py @@ -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 diff --git a/backend/app/dialog/runtime.py b/backend/app/dialog/runtime.py new file mode 100644 index 0000000..ac16b25 --- /dev/null +++ b/backend/app/dialog/runtime.py @@ -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 diff --git a/backend/app/dialog/slots.py b/backend/app/dialog/slots.py new file mode 100644 index 0000000..953256b --- /dev/null +++ b/backend/app/dialog/slots.py @@ -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 + ] diff --git a/backend/app/main.py b/backend/app/main.py index 8202256..05091ce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, } diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py index 7b56ba7..f87aa5e 100644 --- a/backend/app/scenarios/loader.py +++ b/backend/app/scenarios/loader.py @@ -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) diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index 778e433..2c45672 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -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() diff --git a/backend/app/session/state.py b/backend/app/session/state.py index bd9b298..de6d2cf 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index acd1fff..c2cc502 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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", diff --git a/backend/scripts/models.py b/backend/scripts/models.py new file mode 100644 index 0000000..4fe5632 --- /dev/null +++ b/backend/scripts/models.py @@ -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()) diff --git a/backend/scripts/repl.py b/backend/scripts/repl.py new file mode 100644 index 0000000..6b54dbd --- /dev/null +++ b/backend/scripts/repl.py @@ -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() diff --git a/backend/tests/test_slots.py b/backend/tests/test_slots.py new file mode 100644 index 0000000..d802ede --- /dev/null +++ b/backend/tests/test_slots.py @@ -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() diff --git a/backend/tests/test_slots_e5.py b/backend/tests/test_slots_e5.py new file mode 100644 index 0000000..3cd998c --- /dev/null +++ b/backend/tests/test_slots_e5.py @@ -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} мс на реплику" diff --git a/backend/uv.lock b/backend/uv.lock index 38ed38f..caf8d81 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/scenarios/checklists/common.yaml b/scenarios/checklists/common.yaml new file mode 100644 index 0000000..5cc6b2d --- /dev/null +++ b/scenarios/checklists/common.yaml @@ -0,0 +1,19 @@ +# Реплики оператора, которые НЕ являются вопросом чек-листа. +# +# Зачем: матчинг по эмбеддингам сравнивает реплику с формулировками вопросов. +# Без этого списка «Оставайтесь на линии» оказывается ближе к пункту чек-листа, +# чем половина настоящих вопросов, — и звонящий выдаёт адрес на «успокойтесь». +# Реплика засчитывается вопросом, только если она ближе к пункту, чем к любой +# фразе отсюда. +# +# Дополнять, когда в разборе видно, что звонящий «понял вопрос», которого не было. +not_questions: + - "Служба 112, что случилось?" + - "Успокойтесь, пожалуйста" + - "Оставайтесь на линии" + - "Помощь уже едет" + - "Я вас понял" + - "Говорите громче, плохо слышно" + - "Выходите из квартиры" + - "Не кладите трубку" + - "Хорошо, записываю" diff --git a/scenarios/checklists/fire.yaml b/scenarios/checklists/fire.yaml index 62d5ff4..70b458b 100644 --- a/scenarios/checklists/fire.yaml +++ b/scenarios/checklists/fire.yaml @@ -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: ["Как вас зовут?", "Ваш номер телефона?"]