lct-hack/backend/app/dialog/tree.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

86 lines
3.6 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.

"""Офлайн-звонящий: читает таблицу реплик с диска.
Ветвление делает слот-автомат, поэтому таблица индексируется парой
«событие × настроение», а не хранит граф диалога. На занятии сети не нужно
вовсе: задержка — это время матчинга эмбеддингами, около 300 мс.
Это не запасной костыль, а требование воспроизводимости: сценарий занятия
проверяется методистом заранее и звучит одинаково у каждой группы
(docs/arch/STACK.md).
"""
import logging
from pathlib import Path
import yaml
from app.dialog.caller import CallerLine, TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood
log = logging.getLogger(__name__)
LIBRARY = Path(__file__).resolve().parents[3] / "scenarios" / "pregenerated"
def table_path(scenario_id: str) -> Path:
return LIBRARY / f"{scenario_id}.yaml"
def has_table(scenario_id: str) -> bool:
return table_path(scenario_id).exists()
class TreeCaller:
"""Звонящий по предгенерированной таблице.
Чего в таблице нет — берётся у заготовок: пропуск не должен оставлять
звонящего без голоса посреди занятия.
"""
def __init__(self, scenario_id: str) -> None:
self._table = yaml.safe_load(table_path(scenario_id).read_text(encoding="utf-8")) or {}
self._fallback = TemplateCaller()
self._turn = 0
self.misses = 0
@property
def first_line(self) -> str | None:
return self._table.get("first_line")
def _line(self, section: str, fact_id: str, mood: Mood) -> str | None:
return (self._table.get(section, {}).get(fact_id) or {}).get(mood.value)
def _filler(self, mood: Mood) -> str | None:
options = self._table.get("fillers", {}).get(mood.value) or []
return options[(self._turn - 1) % len(options)] if options else None
async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
self._turn += 1
for _ in turn.repeated:
persona.on_repeat()
mood = persona.remember()
parts: list[str] = []
for fact_id in turn.revealed:
section = "refine" if fact_id in turn.refined else "reveal"
parts.append(self._line(section, fact_id, mood) or "")
for fact_id in turn.refined:
if fact_id not in turn.revealed:
parts.append(self._line("refine", fact_id, mood) or "")
for fact_id in turn.repeated:
parts.append(self._line("repeat", fact_id, mood) or "")
if parts and all(parts):
return CallerLine(text=" ".join(parts), mood=mood)
if not parts:
filler = self._filler(mood)
if filler:
return CallerLine(text=filler, mood=mood)
# В таблице дырка: реплику берём у заготовок, но считаем — по счётчику
# видно, что предгенерацию пора повторить.
self.misses += 1
log.warning("в таблице нет реплики (%s), звонящий отвечает заготовкой", mood.value)
return await self._fallback.reply(turn, persona, slots)