lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия. Отказ провайдера не роняет занятие: звонящий откатывается на заготовки. Молчащий звонящий хуже шаблонной фразы. Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат, поэтому таблица индексируется парой «событие × настроение» и выходит небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации. Формулировки в ней от облачной модели, а задержка на занятии нулевая — локальная 3–4B дала бы формулировки хуже и за 2.5 секунды. Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят сотни токенов на размышление и при малом бюджете возвращают пустой content с finish_reason=length. Пустой ответ теперь честный отказ, бюджет токенов поднят, рассуждение из ответа исключается. Живые запросы к LLM вынесены из общего прогона: они требуют сети, а рассуждающая модель отвечает десятками секунд.
This commit is contained in:
parent
34eb88270e
commit
2ceb26f2cd
19 changed files with 888 additions and 26 deletions
|
|
@ -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")
|
||||
|
|
|
|||
44
backend/app/dialog/factory.py
Normal file
44
backend/app/dialog/factory.py
Normal 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
143
backend/app/dialog/llm.py
Normal 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: запись не удалась")
|
||||
21
backend/app/dialog/prompts/caller.md
Normal file
21
backend/app/dialog/prompts/caller.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
Ты — человек, который звонит в службу 112. Ты не оператор и не помощник.
|
||||
|
||||
ПРОИСШЕСТВИЕ: {scenario}
|
||||
ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood}
|
||||
{directive}
|
||||
|
||||
ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ:
|
||||
{revealed}
|
||||
|
||||
ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ:
|
||||
{say_now}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов,
|
||||
ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, —
|
||||
отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите».
|
||||
2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную.
|
||||
3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь.
|
||||
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
|
||||
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
|
||||
6. Отвечай только репликой, без пояснений и без кавычек.
|
||||
15
backend/app/dialog/prompts/pregen.md
Normal file
15
backend/app/dialog/prompts/pregen.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
Ты пишешь реплики для учебного тренажёра службы 112. Отвечает не помощник,
|
||||
а человек, который звонит и просит помощи.
|
||||
|
||||
ПРОИСШЕСТВИЕ: {scenario}
|
||||
СОСТОЯНИЕ ЗВОНЯЩЕГО: {mood}
|
||||
|
||||
ЗАДАЧА: {task}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Одна-две короткие фразы, как в настоящем звонке.
|
||||
2. Ничего не придумывай сверх того, что дано в задаче: ни адресов, ни имён,
|
||||
ни подробностей происшествия.
|
||||
3. Никакого канцелярита и вежливых оборотов помощника.
|
||||
4. Паника и крик — это обрывки, повторы, незаконченные фразы.
|
||||
5. Верни только реплику: без кавычек, без пояснений, без описания действий.
|
||||
4
backend/app/dialog/prompts/repeat.md
Normal file
4
backend/app/dialog/prompts/repeat.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
Оператор ПОВТОРНО спрашивает то, что ты уже говорил: {repeated}
|
||||
|
||||
Ты раздражён: напомни, что уже это сказал, и повтори — но коротко и резко.
|
||||
Чем больше повторов, тем резче. Сейчас это повтор номер {repeats}.
|
||||
82
backend/app/dialog/tree.py
Normal file
82
backend/app/dialog/tree.py
Normal 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)
|
||||
Loading…
Reference in a new issue