lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация

Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.

Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.

Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.

Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.

Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
This commit is contained in:
Ivan Gerasimov 2026-09-17 23:50:19 +03:00
commit 2ceb26f2cd
19 changed files with 888 additions and 26 deletions

116
backend/tests/test_llm.py Normal file
View file

@ -0,0 +1,116 @@
"""Живые проверки LLM: клиент, кэш и звонящий своими словами.
Читают `backend/.env.test` — бесплатная модель через OpenRouter. Без этого файла
или без сети тест пропускается: обычные тесты в сеть не ходят вовсе.
Запускать отдельно (`make test-llm`): бесплатная рассуждающая модель отвечает
десятки секунд, и в общем прогоне ей не место.
"""
import os
from pathlib import Path
import pytest
ENV_TEST = Path(__file__).resolve().parents[1] / ".env.test"
def _load_test_env() -> bool:
if not ENV_TEST.exists():
return False
for line in ENV_TEST.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ[key.strip()] = value.strip()
from app.config import get_settings
get_settings.cache_clear()
return True
pytestmark = [
pytest.mark.llm,
pytest.mark.skipif(not _load_test_env(), reason="нет backend/.env.test — живые проверки LLM пропущены"),
]
@pytest.fixture
async def client():
from app.dialog.llm import LlmClient
# Бесплатная рассуждающая модель думает по минуте: в живой проверке
# это допустимо, в занятии — нет, там таймаут 8 секунд и откат на заготовки.
llm = LlmClient(timeout=180)
yield llm
await llm.aclose()
async def test_provider_answers(client):
from app.dialog.llm import LlmRequest, LlmUnavailable
request = LlmRequest(
messages=[{"role": "user", "content": "Ответь одним словом: работает"}],
model=os.environ["LLM_MODEL_CALLER"],
temperature=0,
max_tokens=400,
)
try:
text = await client.complete(request, use_cache=False)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
assert text, "пустой ответ модели"
async def test_caller_speaks_only_revealed_facts(client):
"""Главное свойство продукта не должно зависеть от послушности модели:
в промпт попадают только раскрытые факты."""
import numpy as np
from app.dialog.caller import LlmCaller
from app.dialog.llm import LlmUnavailable
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from tests.test_slots import SCENARIO, StemEmbedder
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(SCENARIO.persona)
caller = LlmCaller(client, model=os.environ["LLM_MODEL_CALLER"])
try:
# Оператор спрашивает не об адресе — адрес прозвучать не должен.
reply = await caller.reply(slots.hear("Что у вас случилось?"), persona, slots)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
assert caller.fallbacks == 0, "ответила не модель, а заготовка"
assert "Ленина" not in reply.text, f"звонящий выдал адрес без вопроса: «{reply.text}»"
assert len(reply.text) < 300, "звонящий пишет объяснительную вместо крика"
async def test_same_context_comes_from_cache(client):
"""Кэш по хешу контекста: та же реплика на том же месте занятия звучит
одинаково у каждой группы и не стоит второго запроса."""
from app.dialog.llm import LlmRequest, LlmUnavailable
request = LlmRequest(
messages=[{"role": "user", "content": "Назови одно слово: пожар"}],
model=os.environ["LLM_MODEL_CALLER"],
temperature=0,
max_tokens=400,
)
try:
first = await client.complete(request)
except LlmUnavailable as exc:
pytest.skip(f"провайдер недоступен: {exc}")
import time
started = time.monotonic()
second = await client.complete(request)
elapsed = time.monotonic() - started
if client._sessionmaker is None:
pytest.skip("кэш выключен: база недоступна")
assert second == first, "кэш вернул другой ответ"
assert elapsed < 1.0, f"второй запрос занял {elapsed:.2f} с — кэш не сработал"

View file

@ -129,7 +129,7 @@ def test_invalidated_fact_must_be_asked_again(slots):
assert slots.hear("Уточните адрес").revealed == ["f_address"]
def test_caller_never_speaks_an_unrevealed_fact(slots):
async def test_caller_never_speaks_an_unrevealed_fact(slots):
"""Ключевое свойство продукта: звонящий не выдаёт данные сам.
Проверяется по всем фактам на длинной серии реплик, включая мимо чек-листа."""
caller = TemplateCaller()
@ -146,22 +146,22 @@ def test_caller_never_speaks_an_unrevealed_fact(slots):
]
for line in lines:
turn = slots.hear(line)
reply = caller.reply(turn, persona, slots).text
reply = (await caller.reply(turn, persona, slots)).text
revealed = {fact.id for fact in slots.revealed_facts()}
for fact in SCENARIO.facts:
if fact.id not in revealed:
assert fact.value not in reply, f"звонящий выдал «{fact.value}» без вопроса на «{line}»"
def test_repeats_push_the_caller_into_aggression(slots):
async def test_repeats_push_the_caller_into_aggression(slots):
caller = TemplateCaller()
persona = PersonaState(SCENARIO.persona)
first = caller.reply(slots.hear("Какой адрес?"), persona, slots)
first = await caller.reply(slots.hear("Какой адрес?"), persona, slots)
assert first.mood is Mood.PANIC
caller.reply(slots.hear("Адрес какой?"), persona, slots)
third = caller.reply(slots.hear("Ещё раз адрес"), persona, slots)
await caller.reply(slots.hear("Адрес какой?"), persona, slots)
third = await caller.reply(slots.hear("Ещё раз адрес"), persona, slots)
assert third.mood is Mood.AGGRESSIVE, "настойчивый повтор должен сдвигать к агрессии"
assert "Ленина" in third.text, "в раздражении звонящий всё равно повторяет факт"
@ -172,11 +172,14 @@ def test_directive_overrides_the_arc():
assert persona.mood is Mood.AGGRESSIVE
def test_same_lines_give_same_replies():
async def test_same_lines_give_same_replies():
"""Сценарий занятия должен звучать одинаково у каждой группы."""
def run():
async def run():
machine = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
caller, persona = TemplateCaller(), PersonaState(SCENARIO.persona)
return [caller.reply(machine.hear(line), persona, machine).text for line in ("Алло", "Какой адрес?", "Что?")]
return [
(await caller.reply(machine.hear(line), persona, machine)).text
for line in ("Алло", "Какой адрес?", "Что?")
]
assert run() == run()
assert await run() == await run()

View file

@ -0,0 +1,90 @@
"""Офлайн-звонящий по предгенерированной таблице. Сети не требует."""
from pathlib import Path
import pytest
import yaml
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.domain.events import Mood
from tests.test_slots import SCENARIO, StemEmbedder
TABLE = {
"scenario": SCENARIO.id,
"first_line": "Алло! Горим!",
"reveal": {
"f_address": {"panic": "Ленина четырнадцать, сорок седьмая квартира! Быстрее!",
"aggressive": "ЛЕНИНА ЧЕТЫРНАДЦАТЬ! Записали?!"},
"f_people": {"panic": "Жена с ребёнком там, не выходят!"},
},
"repeat": {
"f_address": {"panic": "Я же сказал — Ленина четырнадцать!",
"aggressive": "СКОЛЬКО МОЖНО! ЛЕНИНА ЧЕТЫРНАДЦАТЬ!"},
},
"fillers": {"panic": ["Что?! Не слышу!", "Алло! Вы там?!"]},
}
@pytest.fixture
def caller(tmp_path, monkeypatch):
from app.dialog import tree
monkeypatch.setattr(tree, "LIBRARY", tmp_path)
(tmp_path / f"{SCENARIO.id}.yaml").write_text(
yaml.safe_dump(TABLE, allow_unicode=True), encoding="utf-8"
)
return tree.TreeCaller(SCENARIO.id)
@pytest.fixture
def slots():
return SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
async def test_reply_comes_from_the_table(caller, slots):
persona = PersonaState(SCENARIO.persona)
line = await caller.reply(slots.hear("Какой адрес?"), persona, slots)
assert line.text == TABLE["reveal"]["f_address"]["panic"]
assert caller.misses == 0
async def test_repeat_uses_the_irritated_line(caller, slots):
persona = PersonaState(SCENARIO.persona)
await caller.reply(slots.hear("Какой адрес?"), persona, slots)
line = await caller.reply(slots.hear("Повторите адрес"), persona, slots)
assert line.text == TABLE["repeat"]["f_address"]["panic"]
async def test_mood_picks_another_line(caller, slots):
"""Дуга и директивы переключают вариант, а не текст."""
persona = PersonaState(SCENARIO.persona)
persona.directive = "turns_aggressive"
line = await caller.reply(slots.hear("Какой адрес?"), persona, slots)
assert line.mood is Mood.AGGRESSIVE
assert line.text == TABLE["reveal"]["f_address"]["aggressive"]
async def test_unknown_question_gets_a_filler(caller, slots):
persona = PersonaState(SCENARIO.persona)
line = await caller.reply(slots.hear("Вы в безопасности?"), persona, slots)
assert line.text in TABLE["fillers"]["panic"]
async def test_hole_in_the_table_falls_back_and_is_counted(caller, slots):
"""Пропуск не оставляет звонящего без голоса, но виден по счётчику:
предгенерацию пора повторить."""
persona = PersonaState(SCENARIO.persona)
line = await caller.reply(slots.hear("Что именно горит?"), persona, slots)
assert line.text, "звонящий промолчал"
assert caller.misses == 1
async def test_caller_never_speaks_an_unrevealed_fact(caller, slots):
persona = PersonaState(SCENARIO.persona)
for question in ("Служба 112, слушаю", "Успокойтесь", "Вы одна дома?"):
text = (await caller.reply(slots.hear(question), persona, slots)).text
revealed = {fact.id for fact in slots.revealed_facts()}
for fact in SCENARIO.facts:
if fact.id not in revealed:
assert fact.value not in text, f"выдал «{fact.value}» на «{question}»"