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/
frontend/dist/
# Env
# Env: и .env, и .env.test, и всё, что рядом — там ключи
.env
.env.*
!.env.example
*.local
# Python

View file

@ -47,6 +47,12 @@ seed: ## Залить сценарии из /scenarios в БД
lesson: ## Запустить занятие и напечатать ссылки: make lesson 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: ## Замер задержки голосового контура по этапам — запускать на демо-машине
cd backend && $(UV) run --extra voice python scripts/latency.py
@ -59,10 +65,10 @@ revision: ## Создать миграцию: make revision m="что измен
repl: ## Текстовый диалог со звонящим без голоса: make repl s=<сценарий>
cd backend && $(UV) run python scripts/repl.py "$(s)"
pregen: ## Дерево диалога и WAV первых реплик для офлайна
@echo "не реализовано — карточка tasks/lct-19-reference-dialog.md"; exit 1
pregen: ## Построить таблицу реплик звонящего для офлайна: make pregen s=<сценарий> force=1
cd backend && $(UV) run python scripts/pregenerate.py "$(s)"
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
./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
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 mood_of
from app.dialog.persona import PersonaState
@ -82,7 +82,7 @@ async def _start(session_id: UUID, event) -> None:
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
state.persona = PersonaState(state.scenario.persona)
state.caller = TemplateCaller()
state.caller = build_caller(scenario.id)
hub.register(state)
# Первая реплика и филлеры синтезируются, пока курсант не снял трубку:

View file

@ -8,13 +8,18 @@
Заготовки написаны так же — «алло! алло!», а не «я взволнован».
"""
import logging
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Protocol
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood
log = logging.getLogger(__name__)
@dataclass
class CallerLine:
@ -23,7 +28,10 @@ class CallerLine:
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:
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
if turn.repeated:
for _ in turn.repeated:
@ -101,3 +109,93 @@ def _sentence_case(text: str) -> str:
"""Факт в сценарии записан как фрагмент («улица Ленина, 14»), а в начале
реплики должен звучать как начало фразы."""
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)
started = time.monotonic()
line = self._caller_line(text)
line = await self._caller_line(text)
timing.caller_ms = (time.monotonic() - started) * 1000
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
if state.slots is None or state.caller is None or state.persona is None:
from app.dialog.caller import CallerLine
return CallerLine(text=FILLERS[Mood.PANIC], mood=Mood.PANIC)
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(
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
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]
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
from pathlib import Path
@ -16,7 +17,7 @@ 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 TemplateCaller # noqa: E402
from app.dialog.factory import build_caller # noqa: E402
from app.dialog.embeddings import E5Embedder # noqa: E402
from app.dialog.persona import PersonaState # 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}")
def main() -> None:
async def main() -> None:
scenario_id = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else None
try:
library = {scenario.id: scenario for scenario in load_library(LIBRARY)}
@ -53,7 +54,7 @@ def main() -> None:
slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona)
caller = TemplateCaller()
caller = build_caller(scenario.id)
print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
@ -82,7 +83,7 @@ def main() -> None:
continue
turn = slots.hear(line)
reply = caller.reply(turn, persona, slots)
reply = await caller.reply(turn, persona, slots)
if turn.matched:
print(f" [понято: {', '.join(turn.matched)}]")
print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}")
@ -91,4 +92,4 @@ def main() -> None:
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"]
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}»"