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:
parent
4f5a8d1400
commit
7c97310938
22 changed files with 1073 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
0
backend/app/dialog/__init__.py
Normal file
0
backend/app/dialog/__init__.py
Normal file
103
backend/app/dialog/caller.py
Normal file
103
backend/app/dialog/caller.py
Normal 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
|
||||
72
backend/app/dialog/embeddings.py
Normal file
72
backend/app/dialog/embeddings.py
Normal 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)
|
||||
77
backend/app/dialog/persona.py
Normal file
77
backend/app/dialog/persona.py
Normal 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
|
||||
28
backend/app/dialog/runtime.py
Normal file
28
backend/app/dialog/runtime.py
Normal 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
185
backend/app/dialog/slots.py
Normal 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
|
||||
]
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
50
backend/scripts/models.py
Normal 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
94
backend/scripts/repl.py
Normal 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
182
backend/tests/test_slots.py
Normal 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()
|
||||
96
backend/tests/test_slots_e5.py
Normal file
96
backend/tests/test_slots_e5.py
Normal 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
85
backend/uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue