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
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
|
||||
]
|
||||
Loading…
Reference in a new issue