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

282 lines
13 KiB
Python
Raw Normal View History

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
"""Реплика звонящего.
`TemplateCaller` работает без LLM: говорит только раскрытыми фактами и заготовками
по настроению. Это и офлайн-запасной путь, и режим `make repl`, пока нет ключа.
Звонящий на LLM встанет за тот же интерфейс `Caller`.
70% восприятия эмоции даёт текст: обрывки, повторы, незаконченные фразы.
Заготовки написаны так же — «алло! алло!», а не «я взволнован».
"""
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
import logging
import re
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
from dataclasses import dataclass
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
from functools import lru_cache
from pathlib import Path
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
from typing import Protocol
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood
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
log = logging.getLogger(__name__)
NUMBER_WORDS = {
"один", "одна", "одно", "двое", "два", "две", "трое", "три", "четверо", "четыре",
"пять", "шесть", "семь", "восемь", "девять", "десять",
}
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
@dataclass
class CallerLine:
text: str
mood: Mood
class Caller(Protocol):
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
"""Звонящий. Асинхронный: на LLM он ходит в сеть, на заготовках — нет,
но интерфейс один, и подменяются они друг другом без правок контура."""
async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: ...
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
#: Реплика, когда оператор спросил не то или непонятно. Выбор по номеру реплики,
#: а не случайный: сценарий занятия должен звучать одинаково у каждой группы.
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}... кажется.",
}
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
#: Уточнение: оператор переспросил, и выясняется, что адрес был не тот.
#: Это не повтор — звонящий не раздражается, он поправляется.
REFINE: dict[Mood, str] = {
Mood.PANIC: "Нет, не там! {fact}!",
Mood.WORRIED: "Ой, нет... {fact}.",
Mood.CALM: "Уточню: {fact}.",
Mood.AGGRESSIVE: "Да я же про другое! {fact}!",
Mood.CONFUSED: "А... нет, кажется {fact}...",
}
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
#: Повторный вопрос по уже сказанному.
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
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
async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
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
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:
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
template = REFINE if fact_id in turn.refined else REVEAL
parts.append(template[mood].format(fact=facts[fact_id]))
for fact_id in turn.refined:
if fact_id not in turn.revealed:
parts.append(REFINE[mood].format(fact=facts[fact_id]))
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
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
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
class LlmCaller:
"""Звонящий, говорящий своими словами.
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только
раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали
(docs/product/CALL-SIM.md).
Отказ сети или провайдера не роняет занятие: звонящий откатывается
на заготовки — молчащий звонящий хуже шаблонной фразы.
"""
def __init__(self, client, model: str, temperature: float = 0.8) -> None:
self._client = client
self._model = model
self._temperature = temperature
self._fallback = TemplateCaller()
self._history: list[dict] = []
self.fallbacks = 0
async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
from app.dialog.llm import LlmRequest, LlmUnavailable
if turn.repeated:
for _ in turn.repeated:
persona.on_repeat()
mood = persona.remember()
# Непонятная реплика не передаёт модели право импровизировать фактами.
# Отбор фактов и вопросная карта — только слот-автомат, не LLM.
if not (turn.revealed or turn.refined or turn.repeated):
return await self._fallback.reply(turn, persona, slots)
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
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
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
say_now = [
facts[fact_id]
for fact_id in [*turn.revealed, *turn.refined]
if fact_id in facts
]
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
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
system = _prompt("caller.md").format(
scenario="учебное происшествие",
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
mood=MOOD_WORDS.get(mood, mood.value),
directive=_directive_line(persona),
revealed="\n".join(f"- {value}" for value in facts.values()) or "- пока ничего",
say_now="\n".join(f"- {value}" for value in say_now)
or ("- ничего нового: оператор спросил не о том" if not repeated else ""),
)
if repeated:
system += "\n\n" + _prompt("repeat.md").format(
repeated="; ".join(repeated), repeats=persona.repeats
)
current_message = {"role": "user", "content": turn.text}
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
try:
text = await self._client.complete(
LlmRequest(
messages=[{"role": "system", "content": system},
*self._history[-6:], current_message],
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
model=self._model,
temperature=self._temperature,
max_tokens=160,
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
)
)
except LlmUnavailable as exc:
self.fallbacks += 1
log.warning("звонящий на заготовках: %s", exc)
return await self._fallback.reply(turn, persona, slots)
if not _allowed_reply(text, facts, say_now + repeated, slots):
self.fallbacks += 1
log.warning("ответ модели нарушил протокол раскрытия фактов — использована заготовка")
return await self._fallback.reply(turn, persona, slots)
# Отклонённый ответ и провокационный вопрос не должны загрязнять
# последующий контекст. Запоминаем только проверенную пару ходов.
self._history.extend((current_message, {"role": "assistant", "content": text}))
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
return CallerLine(text=text, mood=mood)
async def aclose(self) -> None:
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:
незакрытый держит событийный цикл и не даёт процессу завершиться."""
await self._client.aclose()
def _allowed_reply(text: str, allowed: dict[str, str], required_now: list[str], slots: SlotMachine) -> bool:
"""Консервативная граница для текста модели; протокол 112 всё равно в коде.
Невозможно доказать истинность произвольной русской фразы регулярками,
поэтому сомнительный ответ заменяется детерминированной репликой.
"""
if not text or len(text) > 300 or "\n" in text or "<think>" in text.lower():
return False
normalized = text.casefold().replace("ё", "е")
# Модель не вправе назвать числовой адрес или телефон, которого нет в
# раскрытых фактах, даже если оператор предположил его в своей реплике.
allowed_digits = set(re.findall(r"\d+", " ".join(allowed.values())))
if any(number not in allowed_digits for number in re.findall(r"\d+", normalized)):
return False
allowed_words = set(re.findall(r"[а-яё]+", " ".join(allowed.values()).casefold().replace("ё", "е")))
spoken_words = set(re.findall(r"[а-яё]+", normalized))
if (spoken_words & NUMBER_WORDS) - allowed_words:
return False
allowed_stems = {word[:4] for word in allowed_words if len(word) >= 4}
spoken_stems = {word[:4] for word in spoken_words if len(word) >= 4}
for fact in slots.scenario.facts:
if fact.id in allowed:
continue
for value in (fact.value, fact.refined):
if not value:
continue
if len(value) >= 7 and value.casefold().replace("ё", "е") in normalized:
return False
# Полная фраза — не единственный способ слить скрытый факт:
# «муж курил» раскрывает причину, даже без «на балконе».
hidden_stems = {word[:4] for word in re.findall(
r"[а-яё]{4,}", value.casefold().replace("ё", "е")
)} - allowed_stems
if hidden_stems & spoken_stems:
return False
# Новый обязательный факт нельзя опустить ради красивой реплики.
for value in required_now:
if value.casefold().replace("ё", "е") not in normalized:
return False
return True
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
MOOD_WORDS = {
Mood.PANIC: "паника, ты кричишь",
Mood.AGGRESSIVE: "злость, ты срываешься на оператора",
Mood.WORRIED: "тревога, ты растерян",
Mood.CALM: "спокойствие, ты собран",
Mood.CONFUSED: "растерянность, ты путаешься",
}
def _directive_line(persona: PersonaState) -> str:
from app.dialog.director import SOFT
if persona.directive in SOFT:
return f"ПРЕПОДАВАТЕЛЬ ВЕДЁТ СИТУАЦИЮ: {SOFT[persona.directive].lower()}."
if persona.directive:
return f"ПРЕПОДАВАТЕЛЬ ПРОСИТ ИЗМЕНИТЬ ПОДАЧУ: {persona.directive}. Факты не меняй."
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
return ""
@lru_cache(maxsize=8)
def _prompt(name: str) -> str:
"""Промпты лежат файлами, а не в коде: их правит тот, кто ведёт занятия."""
return (Path(__file__).parent / "prompts" / name).read_text(encoding="utf-8")