lct-hack/backend/scripts/repl.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

95 lines
3.8 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 repl: текстовый диалог со звонящим, без голоса и без LLM.
Проверяет главную механику до сборки голосового контура: звонящий не выдаёт
данные сам, раскрывает факт на правильный вопрос и злится на повторы.
make repl сценарий по умолчанию
make repl s=fire-apartment-l2 конкретный сценарий
Команды: /подсказка, /факты, /итог, /выход
"""
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.config import get_settings # 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
from app.scenarios.loader import ScenarioError, load_library # noqa: E402
LIBRARY = ROOT.parent / "scenarios"
def summary(slots: SlotMachine) -> None:
required = slots.scenario.ground_truth.required_facts
got = [fact_id for fact_id in required if fact_id in slots.revealed]
print(f"\n добыто обязательных фактов: {len(got)} из {len(required)}")
for fact_id in slots.missing_required():
item = next((i for i in slots.scenario.checklist if i.fact == fact_id), None)
hint = f" — эталонный вопрос: «{item.question}»" if item else ""
print(f" E1 не добыт {fact_id}{hint}")
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)}
except ScenarioError as exc:
raise SystemExit(f"библиотека не прошла проверку: {exc}") from exc
scenario = library.get(scenario_id) if scenario_id else next(iter(library.values()))
if scenario is None:
raise SystemExit(f"нет сценария {scenario_id}; есть: {', '.join(library)}")
try:
embedder = E5Embedder(ROOT / get_settings().models_dir / "e5-small")
except FileNotFoundError as exc:
raise SystemExit(str(exc)) from exc
slots = SlotMachine(scenario, embedder)
persona = PersonaState(scenario.persona)
caller = build_caller(scenario.id)
print(f"── {scenario.title} ({scenario.level.value}) ──")
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
print(f"ЗВОНЯЩИЙ [{persona.mood.value}]: {scenario.first_line}")
while True:
try:
line = input("ОПЕРАТОР: ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line == "/выход":
break
if line == "/итог":
summary(slots)
continue
if line == "/факты":
for fact in slots.revealed_facts():
print(f" раскрыт {fact.id}: {fact.value}")
continue
if line == "/подсказка":
unasked = slots.unasked()
print(f" подсказка: {unasked[0].question}" if unasked else " все пункты чек-листа отработаны")
continue
turn = slots.hear(line)
reply = await caller.reply(turn, persona, slots)
if turn.matched:
print(f" [понято: {', '.join(turn.matched)}]")
print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}")
summary(slots)
if __name__ == "__main__":
asyncio.run(main())