lct-hack/backend/app/dialog/tree.py

89 lines
3.7 KiB
Python
Raw Normal View History

lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
"""Офлайн-звонящий: читает таблицу реплик с диска.
Ветвление делает слот-автомат, поэтому таблица индексируется парой
«событие × настроение», а не хранит граф диалога. На занятии сети не нужно
вовсе: задержка — это время матчинга эмбеддингами, около 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"
_Loader = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
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.load(
table_path(scenario_id).read_text(encoding="utf-8"), Loader=_Loader
) or {}
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
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:
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
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 "")
lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00
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)