diff --git a/.gitignore b/.gitignore index 0677853..54016f7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,10 @@ __pycache__/ node_modules/ frontend/dist/ -# Env +# Env: и .env, и .env.test, и всё, что рядом — там ключи .env +.env.* +!.env.example *.local # Python diff --git a/Makefile b/Makefile index e585e6a..0a3be6a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/backend/app/api/ws/control.py b/backend/app/api/ws/control.py index 2d2cf59..5aec124 100644 --- a/backend/app/api/ws/control.py +++ b/backend/app/api/ws/control.py @@ -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) # Первая реплика и филлеры синтезируются, пока курсант не снял трубку: diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py index e914cb3..a5a9021 100644 --- a/backend/app/dialog/caller.py +++ b/backend/app/dialog/caller.py @@ -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") diff --git a/backend/app/dialog/factory.py b/backend/app/dialog/factory.py new file mode 100644 index 0000000..4fce413 --- /dev/null +++ b/backend/app/dialog/factory.py @@ -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 diff --git a/backend/app/dialog/llm.py b/backend/app/dialog/llm.py new file mode 100644 index 0000000..a752e19 --- /dev/null +++ b/backend/app/dialog/llm.py @@ -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: запись не удалась") diff --git a/backend/app/dialog/prompts/caller.md b/backend/app/dialog/prompts/caller.md new file mode 100644 index 0000000..30614c2 --- /dev/null +++ b/backend/app/dialog/prompts/caller.md @@ -0,0 +1,21 @@ +Ты — человек, который звонит в службу 112. Ты не оператор и не помощник. + +ПРОИСШЕСТВИЕ: {scenario} +ТВОЁ СОСТОЯНИЕ СЕЙЧАС: {mood} +{directive} + +ЧТО ТЫ УЖЕ РАССКАЗАЛ ОПЕРАТОРУ: +{revealed} + +ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ: +{say_now} + +ПРАВИЛА: +1. Говори ТОЛЬКО о том, что перечислено выше. Ничего не придумывай: ни адресов, + ни имён, ни подробностей. Если оператор спрашивает о том, чего в списке нет, — + отвечай уклончиво: «не знаю», «не вижу отсюда», «подождите». +2. Одна-две короткие фразы. Ты звонишь в экстренную службу, а не пишешь объяснительную. +3. Никакого канцелярита и вежливых оборотов помощника. Ты напуган, тебе нужна помощь. +4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы. +5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить. +6. Отвечай только репликой, без пояснений и без кавычек. diff --git a/backend/app/dialog/prompts/pregen.md b/backend/app/dialog/prompts/pregen.md new file mode 100644 index 0000000..1d79444 --- /dev/null +++ b/backend/app/dialog/prompts/pregen.md @@ -0,0 +1,15 @@ +Ты пишешь реплики для учебного тренажёра службы 112. Отвечает не помощник, +а человек, который звонит и просит помощи. + +ПРОИСШЕСТВИЕ: {scenario} +СОСТОЯНИЕ ЗВОНЯЩЕГО: {mood} + +ЗАДАЧА: {task} + +ПРАВИЛА: +1. Одна-две короткие фразы, как в настоящем звонке. +2. Ничего не придумывай сверх того, что дано в задаче: ни адресов, ни имён, + ни подробностей происшествия. +3. Никакого канцелярита и вежливых оборотов помощника. +4. Паника и крик — это обрывки, повторы, незаконченные фразы. +5. Верни только реплику: без кавычек, без пояснений, без описания действий. diff --git a/backend/app/dialog/prompts/repeat.md b/backend/app/dialog/prompts/repeat.md new file mode 100644 index 0000000..3a7acdf --- /dev/null +++ b/backend/app/dialog/prompts/repeat.md @@ -0,0 +1,4 @@ +Оператор ПОВТОРНО спрашивает то, что ты уже говорил: {repeated} + +Ты раздражён: напомни, что уже это сказал, и повтори — но коротко и резко. +Чем больше повторов, тем резче. Сейчас это повтор номер {repeats}. diff --git a/backend/app/dialog/tree.py b/backend/app/dialog/tree.py new file mode 100644 index 0000000..91a184f --- /dev/null +++ b/backend/app/dialog/tree.py @@ -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) diff --git a/backend/app/voice/pipeline.py b/backend/app/voice/pipeline.py index 69dcc3e..56447bc 100644 --- a/backend/app/voice/pipeline.py +++ b/backend/app/voice/pipeline.py @@ -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 diff --git a/backend/conftest.py b/backend/conftest.py index 8b1aa90..1d019a5 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -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", "") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index dc826dd..e3c5986 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -56,3 +56,7 @@ packages = ["app"] [tool.pytest.ini_options] asyncio_mode = "auto" +# Живые запросы к LLM идут отдельно (`make test-llm`): они требуют сети, +# а рассуждающая модель отвечает десятками секунд. +markers = ["llm: живой запрос к провайдеру LLM"] +addopts = "-m 'not llm'" diff --git a/backend/scripts/llm_check.py b/backend/scripts/llm_check.py new file mode 100644 index 0000000..73b537c --- /dev/null +++ b/backend/scripts/llm_check.py @@ -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()) diff --git a/backend/scripts/pregenerate.py b/backend/scripts/pregenerate.py new file mode 100644 index 0000000..01e6636 --- /dev/null +++ b/backend/scripts/pregenerate.py @@ -0,0 +1,155 @@ +"""make pregen: построить таблицу реплик звонящего для офлайна. + +Зачем не «дерево диалога» в буквальном смысле: ветвление уже делает слот-автомат — +он решает, какой факт раскрыт и был ли повтор. Модели остаётся дать формулировки, +поэтому таблица индексируется парой «событие × настроение» и получается небольшой: +для пожарного сценария — 62 реплики. + +Результат кладётся рядом со сценариями, в `scenarios/pregenerated/.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())) diff --git a/backend/scripts/repl.py b/backend/scripts/repl.py index 6b54dbd..7450e56 100644 --- a/backend/scripts/repl.py +++ b/backend/scripts/repl.py @@ -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()) diff --git a/backend/tests/test_llm.py b/backend/tests/test_llm.py new file mode 100644 index 0000000..9719671 --- /dev/null +++ b/backend/tests/test_llm.py @@ -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} с — кэш не сработал" diff --git a/backend/tests/test_slots.py b/backend/tests/test_slots.py index d802ede..a5dffc9 100644 --- a/backend/tests/test_slots.py +++ b/backend/tests/test_slots.py @@ -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() diff --git a/backend/tests/test_tree.py b/backend/tests/test_tree.py new file mode 100644 index 0000000..c1d9152 --- /dev/null +++ b/backend/tests/test_tree.py @@ -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}»"