lct-hack/backend/scripts/pregenerate.py
Ivan Gerasimov 2ceb26f2cd lct-07 и lct-19: звонящий на LLM, офлайн-таблица, предгенерация
Что можно сказать, решает слот-автомат, а не модель: в промпт попадают
только раскрытые факты. Иначе LLM услужливо назовёт адрес, которого не
спрашивали. Промпты лежат файлами — их правит тот, кто ведёт занятия.

Отказ провайдера не роняет занятие: звонящий откатывается на заготовки.
Молчащий звонящий хуже шаблонной фразы.

Офлайн — таблица, а не локальная модель. Ветвление уже делает слот-автомат,
поэтому таблица индексируется парой «событие × настроение» и выходит
небольшой: для пожарного сценария 62 реплики, около 36 секунд генерации.
Формулировки в ней от облачной модели, а задержка на занятии нулевая —
локальная 3–4B дала бы формулировки хуже и за 2.5 секунды.

Найдено на бесплатной модели OpenRouter: рассуждающие модели тратят
сотни токенов на размышление и при малом бюджете возвращают пустой
content с finish_reason=length. Пустой ответ теперь честный отказ,
бюджет токенов поднят, рассуждение из ответа исключается.

Живые запросы к LLM вынесены из общего прогона: они требуют сети,
а рассуждающая модель отвечает десятками секунд.
2026-09-17 23:50:19 +03:00

155 lines
7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()))