lct-hack/backend/app/dialog/slots.py
Ivan Gerasimov 842a333d16 feat: уточнение адреса — место происшествия не равно адресу заявителя (lct-35)
Главный предмет проверки в билетах заказчика — адрес: названный заявителем
часто неверен, а настоящий добывается переспросом («ул. Станционная, 28»
оказывается Королёвом). Памятка АРМ-112 называет это обычным делом.

До этой правки оценка работала наоборот: ground_truth.address сравнивался
с единственным значением факта, поэтому курсант, правильно переспросивший
и записавший настоящий адрес, получал расхождение с эталоном, а записавший
ориентир — зачёт.

- Схема: у факта появились refined и refine_on, задаются только вместе.
- Слот-автомат различает уточнение и повтор: повтор раздражает звонящего,
  уточнение — нет, оператор спросил о другом и получил другое.
- Звонящий поправляется отдельной репликой, а не повторяет прежнее значение;
  офлайн-таблица получила секцию refine.
- Оценка: метрика address_refined (E1, опрос). Сделана отдельной, а не правкой
  метрики address: «записал не тот адрес» и «не спросил» — разные навыки
  и разные компетенции в радаре.
- Общие пункты чек-листа подключаются по требованию сценария: формулировки
  в checklists/common.yaml, сценарий объявляет пункт с тем же id без текста.
  Добавлять «уточните адрес» во все сценарии нельзя — неотработанный пункт
  штрафует за вопрос, которого сценарий не требовал.
- scenarios/fire-private-house-l3.yaml — первый сценарий из библиотеки
  заказчика (билет 3, вызов 1).

Риск механики проверен на живой модели: «Назовите адрес» и «Это точно Москва?»
эмбеддинги не путают, тест это фиксирует.

125 тестов зелёных (12 новых), make typecheck чистый.
2026-09-19 20:26:49 +03:00

217 lines
11 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) # факты, спрошенные повторно
refined: 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)
# Какой пункт чек-листа какой факт уточняет: «это точно Москва?» меняет
# адрес заявителя на адрес происшествия (docs/spec/TICKETS.md).
self._refines: dict[str, list[str]] = {}
for fact in scenario.facts:
if fact.refine_on:
self._refines.setdefault(fact.refine_on, []).append(fact.id)
self.asked: list[str] = []
self.revealed: list[str] = []
self.refined: 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)
# Уточнение — не повтор: оператор спросил о другом и получил другое.
for fact_id in self._refines.get(item.id, []):
if fact_id in self.refined:
continue
self.refined.append(fact_id)
result.refined.append(fact_id)
if fact_id not in self.revealed:
# Уточнили раньше, чем спросили: факт считается раскрытым,
# иначе звонящий промолчит о том, что только что назвал.
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 value_of(self, fact_id: str) -> str:
"""Значение факта с учётом уточнения: до переспроса заявитель называет
ориентир, после — настоящий адрес."""
fact = self._facts[fact_id]
if fact_id in self.refined and fact.refined:
return fact.refined
return fact.value
def revealed_facts(self) -> list[Fact]:
"""Единственное, что уходит в контекст звонящего."""
return [
self._facts[fact_id].model_copy(update={"value": self.value_of(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
]