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

4
.gitignore vendored
View file

@ -14,8 +14,10 @@ __pycache__/
node_modules/ node_modules/
frontend/dist/ frontend/dist/
# Env # Env: и .env, и .env.test, и всё, что рядом — там ключи
.env .env
.env.*
!.env.example
*.local *.local
# Python # Python

View file

@ -47,6 +47,12 @@ seed: ## Залить сценарии из /scenarios в БД
lesson: ## Запустить занятие и напечатать ссылки: make lesson s=<сценарий> m=<режим> lesson: ## Запустить занятие и напечатать ссылки: make lesson s=<сценарий> m=<режим>
cd backend && $(UV) run --no-project --with websockets python scripts/start_lesson.py "$(s)" "$(m)" cd backend && $(UV) run --no-project --with websockets python scripts/start_lesson.py "$(s)" "$(m)"
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env
cd backend && $(UV) run python scripts/llm_check.py
latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине
cd backend && $(UV) run --extra voice python scripts/latency.py cd backend && $(UV) run --extra voice python scripts/latency.py
@ -59,10 +65,10 @@ revision: ## Создать миграцию: make revision m="что измен
repl: ## Текстовый диалог со звонящим без голоса: make repl s=<сценарий> repl: ## Текстовый диалог со звонящим без голоса: make repl s=<сценарий>
cd backend && $(UV) run python scripts/repl.py "$(s)" cd backend && $(UV) run python scripts/repl.py "$(s)"
pregen: ## Дерево диалога и WAV первых реплик для офлайна pregen: ## Построить таблицу реплик звонящего для офлайна: make pregen s=<сценарий> force=1
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1 cd backend && $(UV) run python scripts/pregenerate.py "$(s)"
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./scripts/demo.sh ./scripts/demo.sh
.PHONY: help dev down back front types test test-voice typecheck lesson latency migrate revision models seed repl pregen demo .PHONY: help dev down back front types test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo

View file

@ -31,7 +31,7 @@ from app.domain.events import (
) )
import asyncio import asyncio
from app.dialog.caller import TemplateCaller from app.dialog.factory import build_caller
from app.dialog.director import apply as apply_directive from app.dialog.director import apply as apply_directive
from app.dialog.director import mood_of from app.dialog.director import mood_of
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaState
@ -82,7 +82,7 @@ async def _start(session_id: UUID, event) -> None:
if embedder is not None: if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder) state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona) state.persona = PersonaState(state.scenario.persona)
state.caller = TemplateCaller() state.caller = build_caller(scenario.id)
hub.register(state) hub.register(state)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку: # Первая реплика и филлеры синтезируются, пока курсант не снял трубку:

View file

@ -8,13 +8,18 @@
Заготовки написаны так же — «алло! алло!», а не «я взволнован». Заготовки написаны так же — «алло! алло!», а не «я взволнован».
""" """
import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Protocol from typing import Protocol
from app.dialog.persona import PersonaState from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood from app.domain.events import Mood
log = logging.getLogger(__name__)
@dataclass @dataclass
class CallerLine: class CallerLine:
@ -23,7 +28,10 @@ class CallerLine:
class Caller(Protocol): class Caller(Protocol):
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: ... """Звонящий. Асинхронный: на LLM он ходит в сеть, на заготовках — нет,
но интерфейс один, и подменяются они друг другом без правок контура."""
async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: ...
#: Реплика, когда оператор спросил не то или непонятно. Выбор по номеру реплики, #: Реплика, когда оператор спросил не то или непонятно. Выбор по номеру реплики,
@ -75,7 +83,7 @@ class TemplateCaller:
def __init__(self) -> None: def __init__(self) -> None:
self._turn = 0 self._turn = 0
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: async def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
self._turn += 1 self._turn += 1
if turn.repeated: if turn.repeated:
for _ in turn.repeated: for _ in turn.repeated:
@ -101,3 +109,93 @@ def _sentence_case(text: str) -> str:
"""Факт в сценарии записан как фрагмент («улица Ленина, 14»), а в начале """Факт в сценарии записан как фрагмент («улица Ленина, 14»), а в начале
реплики должен звучать как начало фразы.""" реплики должен звучать как начало фразы."""
return text[:1].upper() + text[1:] if text else text return text[:1].upper() + text[1:] if text else text
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()
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
say_now = [facts[fact_id] for fact_id in turn.revealed if fact_id in facts]
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
system = _prompt("caller.md").format(
scenario=slots.scenario.title,
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
)
self._history.append({"role": "user", "content": turn.text})
try:
text = await self._client.complete(
LlmRequest(
messages=[{"role": "system", "content": system}, *self._history[-6:]],
model=self._model,
temperature=self._temperature,
)
)
except LlmUnavailable as exc:
self.fallbacks += 1
log.warning("звонящий на заготовках: %s", exc)
return await self._fallback.reply(turn, persona, slots)
self._history.append({"role": "assistant", "content": text})
return CallerLine(text=text, mood=mood)
async def aclose(self) -> None:
"""Сетевой клиент живёт, пока идёт занятие, и закрывается вместе с ним:
незакрытый держит событийный цикл и не даёт процессу завершиться."""
await self._client.aclose()
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()}."
return ""
@lru_cache(maxsize=8)
def _prompt(name: str) -> str:
"""Промпты лежат файлами, а не в коде: их правит тот, кто ведёт занятия."""
return (Path(__file__).parent / "prompts" / name).read_text(encoding="utf-8")

View file

@ -0,0 +1,44 @@
"""Кто играет звонящего: LLM, если есть ключ, иначе заготовки.
Провайдер и модель меняются значением в конфиге, а не кодом. Заготовки —
не запасной костыль, а рабочий режим: занятие идёт и без сети.
"""
import logging
from app.config import get_settings
from app.dialog.caller import Caller, LlmCaller, TemplateCaller
from app.dialog.llm import LlmClient
from app.dialog.tree import TreeCaller, has_table
from app.db.base import get_sessionmaker
log = logging.getLogger(__name__)
def build_caller(scenario_id: str | None = None, sessionmaker=None) -> Caller:
settings = get_settings()
# Офлайн и «нет ключа» — это один и тот же путь: предгенерированная таблица,
# а не локальная модель. Формулировки в ней от облачной модели, а задержка
# нулевая (docs/arch/STACK.md).
if (settings.offline or not settings.llm_api_key) and scenario_id and has_table(scenario_id):
log.info("звонящий по предгенерированной таблице сценария %s", scenario_id)
return TreeCaller(scenario_id)
if not settings.llm_api_key or settings.offline:
reason = "офлайн-режим" if settings.offline else "нет ключа LLM"
log.info("звонящий отвечает заготовками: %s (таблицы нет — make pregen)", reason)
return TemplateCaller()
client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker())
log.info("звонящий на модели %s", settings.llm_model_caller)
return LlmCaller(client, model=settings.llm_model_caller)
def _safe_sessionmaker():
"""Кэш в Postgres — приятный бонус, а не условие работы звонящего."""
try:
return get_sessionmaker()
except Exception: # noqa: BLE001
log.warning("кэш LLM выключен: база недоступна")
return None

143
backend/app/dialog/llm.py Normal file
View file

@ -0,0 +1,143 @@
"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге.
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
и онлайн — экономия и ускорение повторов, — и как накопитель материала
для офлайн-дерева.
"""
import hashlib
import json
import logging
from dataclasses import dataclass
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.config import get_settings
from app.db.models import LlmCache
log = logging.getLogger(__name__)
class LlmUnavailable(RuntimeError):
"""Сеть, ключ или провайдер отказали. Звонящий откатывается на заготовки,
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
@dataclass
class LlmRequest:
messages: list[dict]
model: str
temperature: float = 0.8
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
max_tokens: int = 400
def cache_key(self) -> str:
payload = json.dumps(
{"m": self.model, "t": self.temperature, "msgs": self.messages},
ensure_ascii=False,
sort_keys=True,
)
return hashlib.sha256(payload.encode()).hexdigest()
class LlmClient:
def __init__(
self,
*,
sessionmaker: async_sessionmaker | None = None,
transport: httpx.AsyncBaseTransport | None = None,
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
# с заготовками ответит сразу.
timeout: float = 8.0,
) -> None:
settings = get_settings()
self._base_url = settings.llm_base_url.rstrip("/")
self._key = settings.llm_api_key
self._sessionmaker = sessionmaker
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
@property
def configured(self) -> bool:
return bool(self._key and self._base_url)
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
занятия звучит одинаково у каждой группы."""
if not self.configured:
raise LlmUnavailable("не задан ключ или адрес провайдера")
key = request.cache_key()
if use_cache:
cached = await self._from_cache(key)
if cached is not None:
return cached
try:
response = await self._client.post(
f"{self._base_url}/chat/completions",
headers={"Authorization": f"Bearer {self._key}"},
json={
"model": request.model,
"messages": request.messages,
"temperature": request.temperature,
"max_tokens": request.max_tokens,
# Рассуждение в ответе не нужно: оно только раздувает трафик.
# Провайдеры, которые про это поле не знают, его игнорируют.
"reasoning": {"exclude": True},
},
)
except httpx.HTTPError as exc:
raise LlmUnavailable(f"{type(exc).__name__}") from exc
if response.status_code != 200:
# Тело ошибки в лог, ключ в заголовке — не логируется.
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
message = response.json()["choices"][0]["message"]
text = (message.get("content") or "").strip()
if not text:
# У рассуждающих моделей при нехватке бюджета весь ответ уходит
# в размышление, а content приходит пустым. Для занятия это отказ:
# звонящий откатится на заготовку, а не промолчит.
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
if use_cache and text:
await self._to_cache(key, request, text)
return text
async def aclose(self) -> None:
await self._client.aclose()
# ── кэш ──
async def _from_cache(self, key: str) -> str | None:
if self._sessionmaker is None:
return None
try:
async with self._sessionmaker() as db:
return await db.scalar(
select(LlmCache.response).where(LlmCache.context_hash == key)
)
except Exception: # noqa: BLE001 — без кэша занятие идёт, без базы тоже
log.exception("кэш LLM: чтение не удалось")
return None
async def _to_cache(self, key: str, request: LlmRequest, text: str) -> None:
if self._sessionmaker is None:
return
try:
async with self._sessionmaker() as db:
db.add(
LlmCache(
context_hash=key,
model=request.model,
prompt=json.dumps(request.messages, ensure_ascii=False)[:8000],
response=text,
)
)
await db.commit()
except Exception: # noqa: BLE001
log.exception("кэш LLM: запись не удалась")

View file

@ -0,0 +1,21 @@
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
ПРОИСШЕСТВИЕ: {scenario}
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
{directive}
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
{revealed}
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
{say_now}
ПРАВИЛА:
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.

View file

@ -0,0 +1,15 @@
Ты пишешь реплики для учебного тренажёра службы 112. Отвечает не помощник,
а человек, который звонит и просит помощи.
ПРОИСШЕСТВИЕ: {scenario}
СОСТОЯНИЕ ЗВОНЯЩЕГО: {mood}
ЗАДАЧА: {task}
ПРАВИЛА:
1. Одна-две короткие фразы, как в настоящем звонке.
2. Ничего не придумывай сверх того, что дано в задаче: ни адресов, ни имён,
ни подробностей происшествия.
3. Никакого канцелярита и вежливых оборотов помощника.
4. Паника и крик — это обрывки, повторы, незаконченные фразы.
5. Верни только реплику: без кавычек, без пояснений, без описания действий.

View file

@ -0,0 +1,4 @@
Оператор ПОВТОРНО спрашивает то, что ты уже говорил: {repeated}
Ты раздражён: напомни, что уже это сказал, и повтори — но коротко и резко.
Чем больше повторов, тем резче. Сейчас это повтор номер {repeats}.

View file

@ -0,0 +1,82 @@
"""Офлайн-звонящий: читает таблицу реплик с диска.
Ветвление делает слот-автомат, поэтому таблица индексируется парой
«событие × настроение», а не хранит граф диалога. На занятии сети не нужно
вовсе: задержка — это время матчинга эмбеддингами, около 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:
parts.append(self._line("reveal", 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)

View file

@ -153,18 +153,18 @@ class VoiceSession:
await self.journal.utterance(self.session_id, entry) await self.journal.utterance(self.session_id, entry)
started = time.monotonic() started = time.monotonic()
line = self._caller_line(text) line = await self._caller_line(text)
timing.caller_ms = (time.monotonic() - started) * 1000 timing.caller_ms = (time.monotonic() - started) * 1000
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing) await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
def _caller_line(self, text: str): async def _caller_line(self, text: str):
state = self.state state = self.state
if state.slots is None or state.caller is None or state.persona is None: if state.slots is None or state.caller is None or state.persona is None:
from app.dialog.caller import CallerLine from app.dialog.caller import CallerLine
return CallerLine(text=FILLERS[Mood.PANIC], mood=Mood.PANIC) return CallerLine(text=FILLERS[Mood.PANIC], mood=Mood.PANIC)
turn = state.slots.hear(text) turn = state.slots.hear(text)
return state.caller.reply(turn, state.persona, state.slots) return await state.caller.reply(turn, state.persona, state.slots)
async def say( async def say(
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None

View file

@ -8,3 +8,8 @@ sys.path.insert(0, str(Path(__file__).parent))
import os import os
os.environ.setdefault("VOICE_ENABLED", "false") os.environ.setdefault("VOICE_ENABLED", "false")
# И в сеть они не ходят: звонящий отвечает заготовками, иначе каждый тест
# ждал бы ответа провайдера и зависел от его настроения.
# Живые проверки LLM — в test_llm.py, он читает backend/.env.test.
os.environ.setdefault("LLM_API_KEY", "")

View file

@ -56,3 +56,7 @@ packages = ["app"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
# Живые запросы к LLM идут отдельно (`make test-llm`): они требуют сети,
# а рассуждающая модель отвечает десятками секунд.
markers = ["llm: живой запрос к провайдеру LLM"]
addopts = "-m 'not llm'"

View file

@ -0,0 +1,73 @@
"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие.
Ключ берётся из backend/.env и никуда не печатается. Запускать там, где есть
сеть до провайдера: из WSL под VPN российские адреса недоступны.
"""
import sys
import time
from pathlib import Path
import httpx
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402
def main() -> int:
settings = get_settings()
if not settings.llm_api_key:
print("в backend/.env нет LLM_API_KEY")
return 1
print(f"адрес: {settings.llm_base_url}")
print(f"модель: {settings.llm_model_caller}")
body = {
"model": settings.llm_model_caller,
"messages": [
{"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. "
"Ответь одной короткой фразой, в панике."},
{"role": "user", "content": "Служба 112, что у вас случилось?"},
],
"max_tokens": 60,
"temperature": 0.8,
}
started = time.monotonic()
try:
response = httpx.post(
f"{settings.llm_base_url}/chat/completions",
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
json=body,
timeout=60,
)
except httpx.HTTPError as exc:
print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.")
print("Проверь, что российские адреса идут мимо VPN.")
return 2
elapsed = time.monotonic() - started
print(f"\nHTTP {response.status_code}, {elapsed:.2f} с")
if response.status_code == 200:
data = response.json()
print("ответ модели:", data["choices"][0]["message"]["content"].strip())
usage = data.get("usage", {})
print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens"))
print("\nКЛЮЧ РАБОТАЕТ")
return 0
print("ответ сервера:", response.text[:400])
if response.status_code in (401, 403):
print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять "
"на временный токен — сверься с консолью Cloud.ru.")
elif response.status_code == 404:
print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.")
return 3
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,155 @@
"""make pregen: построить таблицу реплик звонящего для офлайна.
Зачем не «дерево диалога» в буквальном смысле: ветвление уже делает слот-автомат —
он решает, какой факт раскрыт и был ли повтор. Модели остаётся дать формулировки,
поэтому таблица индексируется парой «событие × настроение» и получается небольшой:
для пожарного сценария — 62 реплики.
Результат кладётся рядом со сценариями, в `scenarios/pregenerated/<id>.yaml`:
это контент, и методист должен иметь возможность его прочитать и поправить.
Сеть нужна только здесь. На занятии таблица читается с диска, и звонящий
отвечает мгновенно, без интернета.
make pregen все сценарии
make pregen s=fire-apartment-l2 один
make pregen force=1 перегенерировать уже готовое
"""
import asyncio
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402
from app.dialog.caller import MOOD_WORDS, _prompt # noqa: E402
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402
from app.domain.events import Mood # noqa: E402
from app.scenarios.loader import load_library # noqa: E402
LIBRARY = ROOT.parent / "scenarios"
OUTPUT = LIBRARY / "pregenerated"
#: Сколько филлеров «не понял вопрос» на каждое настроение: подряд одинаковая
#: фраза звучит как заевшая пластинка.
FILLERS_PER_MOOD = 3
async def ask(client: LlmClient, model: str, scenario_title: str, mood: Mood, task: str) -> str | None:
prompt = _prompt("pregen.md").format(
scenario=scenario_title, mood=MOOD_WORDS.get(mood, mood.value), task=task
)
for attempt in range(1, 4):
try:
return await client.complete(
LlmRequest(
messages=[{"role": "user", "content": prompt}],
model=model,
temperature=0.9,
),
use_cache=False,
)
except LlmUnavailable as exc:
print(f" попытка {attempt}: {exc}", flush=True)
await asyncio.sleep(3 * attempt)
return None
async def build(client: LlmClient, model: str, scenario, existing: dict, force: bool) -> dict:
table = {
"scenario": scenario.id,
"model": model,
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"first_line": scenario.first_line,
"reveal": dict(existing.get("reveal", {})),
"repeat": dict(existing.get("repeat", {})),
"fillers": dict(existing.get("fillers", {})),
}
moods = list(MOOD_WORDS)
facts = {fact.id: fact.value for fact in scenario.facts}
total = len(facts) * len(moods) * 2 + len(moods) * FILLERS_PER_MOOD
done = 0
for fact_id, value in facts.items():
for section, task in (
("reveal", f"Оператор спросил, и ты отвечаешь ему вот этим фактом: «{value}». Скажи это своими словами."),
("repeat", f"Оператор ПОВТОРНО спрашивает то, что ты уже говорил: «{value}». "
f"Ты раздражён: напомни, что уже сказал, и повтори коротко."),
):
table[section].setdefault(fact_id, {})
for mood in moods:
done += 1
if not force and table[section][fact_id].get(mood.value):
continue
line = await ask(client, model, scenario.title, mood, task)
if line:
table[section][fact_id][mood.value] = line
print(f" [{done}/{total}] {section} {fact_id} {mood.value}: {line or 'НЕ ПОЛУЧЕНО'}", flush=True)
for mood in moods:
table["fillers"].setdefault(mood.value, [])
for index in range(FILLERS_PER_MOOD):
done += 1
if not force and len(table["fillers"][mood.value]) > index:
continue
line = await ask(
client, model, scenario.title, mood,
"Оператор спросил о том, чего ты не знаешь, или ты не расслышал вопрос. "
"Переспроси или отмахнись — фактов не называй.",
)
if line:
table["fillers"][mood.value].append(line)
print(f" [{done}/{total}] filler {mood.value}: {line or 'НЕ ПОЛУЧЕНО'}", flush=True)
return table
async def main() -> int:
settings = get_settings()
if not settings.llm_api_key:
print("нет LLM_API_KEY в backend/.env — генерировать нечем")
return 1
only = os.environ.get("s") or (sys.argv[1] if len(sys.argv) > 1 else "")
force = bool(os.environ.get("force"))
scenarios = [s for s in load_library(LIBRARY) if not only or s.id == only]
if not scenarios:
print(f"нет сценария {only}")
return 1
print(f"модель: {settings.llm_model_caller}")
OUTPUT.mkdir(parents=True, exist_ok=True)
client = LlmClient(timeout=180) # рассуждающие модели отвечают долго
try:
for scenario in scenarios:
path = OUTPUT / f"{scenario.id}.yaml"
existing = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {}
print(f"\n{scenario.id} — {scenario.title}")
table = await build(client, settings.llm_model_caller, scenario, existing or {}, force)
path.write_text(
yaml.safe_dump(table, allow_unicode=True, sort_keys=False, width=100),
encoding="utf-8",
)
missing = sum(
1
for section in ("reveal", "repeat")
for fact in table[section].values()
for mood in MOOD_WORDS
if not fact.get(mood.value)
)
print(f" → {path.relative_to(ROOT.parent)}" + (f", не получено реплик: {missing}" if missing else ""))
finally:
await client.aclose()
print("\nГотово. Занятие теперь идёт без сети: звонящий читает таблицу с диска.")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -9,6 +9,7 @@
Команды: /подсказка, /факты, /итог, /выход Команды: /подсказка, /факты, /итог, /выход
""" """
import asyncio
import sys import sys
from pathlib import Path from pathlib import Path
@ -16,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from app.config import get_settings # noqa: E402 from app.config import get_settings # noqa: E402
from app.dialog.caller import TemplateCaller # noqa: E402 from app.dialog.factory import build_caller # noqa: E402
from app.dialog.embeddings import E5Embedder # noqa: E402 from app.dialog.embeddings import E5Embedder # noqa: E402
from app.dialog.persona import PersonaState # noqa: E402 from app.dialog.persona import PersonaState # noqa: E402
from app.dialog.slots import SlotMachine # noqa: E402 from app.dialog.slots import SlotMachine # noqa: E402
@ -35,7 +36,7 @@ def summary(slots: SlotMachine) -> None:
print(f" E1 не добыт {fact_id}{hint}") print(f" E1 не добыт {fact_id}{hint}")
def main() -> None: async def main() -> None:
scenario_id = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else None scenario_id = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else None
try: try:
library = {scenario.id: scenario for scenario in load_library(LIBRARY)} library = {scenario.id: scenario for scenario in load_library(LIBRARY)}
@ -53,7 +54,7 @@ def main() -> None:
slots = SlotMachine(scenario, embedder) slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona) persona = PersonaState(scenario.persona)
caller = TemplateCaller() caller = build_caller(scenario.id)
print(f"── {scenario.title} ({scenario.level.value}) ──") print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n") print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
@ -82,7 +83,7 @@ def main() -> None:
continue continue
turn = slots.hear(line) turn = slots.hear(line)
reply = caller.reply(turn, persona, slots) reply = await caller.reply(turn, persona, slots)
if turn.matched: if turn.matched:
print(f" [понято: {', '.join(turn.matched)}]") print(f" [понято: {', '.join(turn.matched)}]")
print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}") print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}")
@ -91,4 +92,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() asyncio.run(main())

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"] 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() caller = TemplateCaller()
@ -146,22 +146,22 @@ def test_caller_never_speaks_an_unrevealed_fact(slots):
] ]
for line in lines: for line in lines:
turn = slots.hear(line) 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()} revealed = {fact.id for fact in slots.revealed_facts()}
for fact in SCENARIO.facts: for fact in SCENARIO.facts:
if fact.id not in revealed: if fact.id not in revealed:
assert fact.value not in reply, f"звонящий выдал «{fact.value}» без вопроса на «{line}»" 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() caller = TemplateCaller()
persona = PersonaState(SCENARIO.persona) 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 assert first.mood is Mood.PANIC
caller.reply(slots.hear("Адрес какой?"), persona, slots) await caller.reply(slots.hear("Адрес какой?"), persona, slots)
third = caller.reply(slots.hear("Ещё раз адрес"), persona, slots) third = await caller.reply(slots.hear("Ещё раз адрес"), persona, slots)
assert third.mood is Mood.AGGRESSIVE, "настойчивый повтор должен сдвигать к агрессии" assert third.mood is Mood.AGGRESSIVE, "настойчивый повтор должен сдвигать к агрессии"
assert "Ленина" in third.text, "в раздражении звонящий всё равно повторяет факт" assert "Ленина" in third.text, "в раздражении звонящий всё равно повторяет факт"
@ -172,11 +172,14 @@ def test_directive_overrides_the_arc():
assert persona.mood is Mood.AGGRESSIVE 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) machine = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
caller, persona = TemplateCaller(), PersonaState(SCENARIO.persona) 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}»"