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

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