lct-hack/backend/app/dialog/slots.py
Ivan Gerasimov 7c97310938 lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.

Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».

Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.

Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.

Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.

Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00

185 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Слот-автомат: какие факты раскрыты, какие вопросы заданы.
**Звонящий не выдаёт информацию сам.** Автомат держит состояние явно и отдаёт
звонящему только раскрытые факты — иначе 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
]