новое чуть
This commit is contained in:
parent
9b74e3e524
commit
e081fa410b
103 changed files with 9405 additions and 76 deletions
|
|
@ -2,14 +2,20 @@
|
|||
а человек, который звонит и просит помощи.
|
||||
|
||||
ПРОИСШЕСТВИЕ: {scenario}
|
||||
СОСТОЯНИЕ ЗВОНЯЩЕГО: {mood}
|
||||
|
||||
ЗАДАЧА: {task}
|
||||
|
||||
Дай по одной реплике на каждое состояние звонящего:
|
||||
{moods}
|
||||
|
||||
ПРАВИЛА:
|
||||
1. Одна-две короткие фразы, как в настоящем звонке.
|
||||
2. Ничего не придумывай сверх того, что дано в задаче: ни адресов, ни имён,
|
||||
ни подробностей происшествия.
|
||||
3. Никакого канцелярита и вежливых оборотов помощника.
|
||||
4. Паника и крик — это обрывки, повторы, незаконченные фразы.
|
||||
5. Верни только реплику: без кавычек, без пояснений, без описания действий.
|
||||
5. Реплики для разных состояний должны заметно отличаться подачей,
|
||||
но нести одно и то же содержание.
|
||||
|
||||
ФОРМАТ ОТВЕТА: только JSON-объект, ключи — коды состояний из списка выше,
|
||||
значения — реплики. Без пояснений, без markdown-ограды.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
"""make pregen: построить таблицу реплик звонящего для офлайна.
|
||||
"""make pregen: таблица реплик звонящего для офлайна.
|
||||
|
||||
Зачем не «дерево диалога» в буквальном смысле: ветвление уже делает слот-автомат —
|
||||
он решает, какой факт раскрыт и был ли повтор. Модели остаётся дать формулировки,
|
||||
поэтому таблица индексируется парой «событие × настроение» и получается небольшой:
|
||||
для пожарного сценария — 62 реплики.
|
||||
|
||||
Результат кладётся рядом со сценариями, в `scenarios/pregenerated/<id>.yaml`:
|
||||
это контент, и методист должен иметь возможность его прочитать и поправить.
|
||||
он решает, какой факт раскрыт, был ли повтор и уточнён ли адрес. Модели остаётся
|
||||
дать формулировки, поэтому таблица индексируется парой «событие × настроение».
|
||||
|
||||
Сеть нужна только здесь. На занятии таблица читается с диска, и звонящий
|
||||
отвечает мгновенно, без интернета.
|
||||
|
||||
**Один запрос на факт, а не на реплику.** В библиотеке 96 вызовов заказчика,
|
||||
449 фактов и пять настроений: по реплике за запрос это шесть тысяч обращений
|
||||
и часы ожидания. Модель отдаёт все настроения одного факта разом, а запросы
|
||||
идут параллельно — это минуты.
|
||||
|
||||
Работа возобновляемая: уже заполненные реплики пропускаются, так что прерванный
|
||||
прогон продолжается с места остановки.
|
||||
|
||||
make pregen все сценарии
|
||||
make pregen s=fire-apartment-l2 один
|
||||
make pregen force=1 перегенерировать уже готовое
|
||||
make pregen s=t01-1-fire-container один
|
||||
make pregen t=19 все вызовы билета 19
|
||||
make pregen force=1 перегенерировать заполненное
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -30,7 +37,6 @@ 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"
|
||||
|
|
@ -40,28 +46,100 @@ OUTPUT = LIBRARY / "pregenerated"
|
|||
#: фраза звучит как заевшая пластинка.
|
||||
FILLERS_PER_MOOD = 3
|
||||
|
||||
#: Одновременных запросов к провайдеру. Восемь — компромисс: быстрее упираемся
|
||||
#: в ограничение частоты у провайдера, медленнее ждём зря.
|
||||
CONCURRENCY = int(os.environ.get("PREGEN_CONCURRENCY", "8"))
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def moods_block() -> str:
|
||||
return "\n".join(f" {mood.value} — {words}" for mood, words in MOOD_WORDS.items())
|
||||
|
||||
|
||||
def parse(text: str) -> dict[str, str]:
|
||||
"""JSON из ответа модели. Рассуждающие модели любят обернуть его в ограду
|
||||
или дописать фразу вокруг — вынимаем первый объект и не спорим."""
|
||||
match = re.search(r"\{.*\}", text, re.S)
|
||||
if not match:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {
|
||||
key: value.strip()
|
||||
for key, value in data.items()
|
||||
if isinstance(value, str) and value.strip()
|
||||
}
|
||||
|
||||
|
||||
async def ask(client: LlmClient, model: str, scenario_title: str, task: str) -> dict[str, str]:
|
||||
"""Реплики на все настроения одним запросом."""
|
||||
prompt = _prompt("pregen.md").format(scenario=scenario_title, task=task, moods=moods_block())
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
return await client.complete(
|
||||
answer = await client.complete(
|
||||
LlmRequest(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
model=model,
|
||||
temperature=0.9,
|
||||
max_tokens=700,
|
||||
),
|
||||
use_cache=False,
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
print(f" попытка {attempt}: {exc}", flush=True)
|
||||
await asyncio.sleep(3 * attempt)
|
||||
return None
|
||||
if attempt == 3:
|
||||
print(f" не получено: {exc}", flush=True)
|
||||
return {}
|
||||
# 429 — не сбой, а просьба подождать. Три секунды провайдеру
|
||||
# ни о чём не говорят, и прогон сгорает на ровном месте.
|
||||
pause = (20 if "429" in str(exc) else 3) * attempt
|
||||
await asyncio.sleep(pause)
|
||||
continue
|
||||
lines = parse(answer)
|
||||
if lines:
|
||||
return lines
|
||||
return {}
|
||||
|
||||
|
||||
async def build(client: LlmClient, model: str, scenario, existing: dict, force: bool) -> dict:
|
||||
def tasks_for(scenario, table: dict, force: bool) -> list[tuple[str, str, str]]:
|
||||
"""Что осталось сгенерировать: (секция, ключ, задание для модели)."""
|
||||
todo: list[tuple[str, str, str]] = []
|
||||
known = set(MOOD_WORDS)
|
||||
|
||||
def missing(section: str, key: str) -> bool:
|
||||
if force:
|
||||
return True
|
||||
have = table.get(section, {}).get(key) or {}
|
||||
return not all(have.get(mood.value) for mood in known)
|
||||
|
||||
for fact in scenario.facts:
|
||||
if missing("reveal", fact.id):
|
||||
todo.append(("reveal", fact.id,
|
||||
f"Оператор спросил, и ты отвечаешь ему вот этим фактом: «{fact.value}». "
|
||||
"Скажи это своими словами."))
|
||||
if missing("repeat", fact.id):
|
||||
todo.append(("repeat", fact.id,
|
||||
f"Оператор ПОВТОРНО спрашивает то, что ты уже говорил: «{fact.value}». "
|
||||
"Ты раздражён: напомни, что уже сказал, и повтори коротко."))
|
||||
# Уточнение: заявитель назвал не тот адрес и теперь поправляется.
|
||||
# Без этой секции офлайн-звонящий на уточняющий вопрос ответит
|
||||
# заготовкой и уточнённого адреса не назовёт (tasks/lct-35).
|
||||
if fact.refined and missing("refine", fact.id):
|
||||
todo.append(("refine", fact.id,
|
||||
f"Оператор переспросил, и выясняется, что ты назвал не то: на самом деле "
|
||||
f"«{fact.refined}». Поправься — не раздражаясь, ты сам ошибся."))
|
||||
if force or not all(
|
||||
len(table.get("fillers", {}).get(mood.value) or []) >= FILLERS_PER_MOOD for mood in known
|
||||
):
|
||||
for index in range(FILLERS_PER_MOOD):
|
||||
todo.append(("fillers", str(index),
|
||||
"Оператор спросил о том, чего ты не знаешь, или ты не расслышал вопрос. "
|
||||
"Переспроси или отмахнись — фактов не называй."))
|
||||
return todo
|
||||
|
||||
|
||||
async def build(client: LlmClient, model: str, scenario, existing: dict, force: bool,
|
||||
gate: asyncio.Semaphore) -> tuple[dict, int]:
|
||||
table = {
|
||||
"scenario": scenario.id,
|
||||
"model": model,
|
||||
|
|
@ -69,45 +147,30 @@ async def build(client: LlmClient, model: str, scenario, existing: dict, force:
|
|||
"first_line": scenario.first_line,
|
||||
"reveal": dict(existing.get("reveal", {})),
|
||||
"repeat": dict(existing.get("repeat", {})),
|
||||
"refine": dict(existing.get("refine", {})),
|
||||
"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
|
||||
todo = tasks_for(scenario, table, force)
|
||||
if not todo:
|
||||
return table, 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)
|
||||
async def one(section: str, key: str, task: str):
|
||||
async with gate:
|
||||
return section, key, await ask(client, model, scenario.title, task)
|
||||
|
||||
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)
|
||||
results = await asyncio.gather(*(one(*item) for item in todo))
|
||||
|
||||
return table
|
||||
failed = 0
|
||||
for section, key, lines in results:
|
||||
if not lines:
|
||||
failed += 1
|
||||
continue
|
||||
if section == "fillers":
|
||||
for mood, line in lines.items():
|
||||
table["fillers"].setdefault(mood, []).append(line)
|
||||
else:
|
||||
table[section].setdefault(key, {}).update(lines)
|
||||
return table, failed
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
|
|
@ -116,38 +179,60 @@ async def main() -> int:
|
|||
print("нет LLM_API_KEY в backend/.env — генерировать нечем")
|
||||
return 1
|
||||
|
||||
only = os.environ.get("s") or (sys.argv[1] if len(sys.argv) > 1 else "")
|
||||
only = os.environ.get("s") or ""
|
||||
ticket = os.environ.get("t") or ""
|
||||
force = bool(os.environ.get("force"))
|
||||
scenarios = [s for s in load_library(LIBRARY) if not only or s.id == only]
|
||||
|
||||
scenarios = load_library(LIBRARY)
|
||||
if only:
|
||||
scenarios = [s for s in scenarios if s.id == only]
|
||||
if ticket:
|
||||
scenarios = [s for s in scenarios if str(s.ticket) == ticket]
|
||||
if not scenarios:
|
||||
print(f"нет сценария {only}")
|
||||
print(f"нет сценариев по условию s={only!r} t={ticket!r}")
|
||||
return 1
|
||||
|
||||
print(f"модель: {settings.llm_model_caller}")
|
||||
# Прикидка до запуска: человек должен понимать, что он запускает —
|
||||
# тысячу обращений к платному провайдеру или десяток.
|
||||
planned = 0
|
||||
for scenario in scenarios:
|
||||
path = OUTPUT / f"{scenario.id}.yaml"
|
||||
existing = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
planned += len(tasks_for(scenario, existing or {}, force))
|
||||
# flush обязателен: вывод уходит в трубу, а человек должен увидеть оценку
|
||||
# объёма до того, как уйдёт первый запрос, а не после всего прогона.
|
||||
print(f"модель: {settings.llm_model_caller}, сценариев: {len(scenarios)}, "
|
||||
f"запросов: {planned}, параллельно: {CONCURRENCY}", flush=True)
|
||||
if not planned:
|
||||
print("всё уже сгенерировано (перегенерировать: make pregen force=1)", flush=True)
|
||||
return 0
|
||||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||||
client = LlmClient(timeout=180) # рассуждающие модели отвечают долго
|
||||
gate = asyncio.Semaphore(CONCURRENCY)
|
||||
started = datetime.now(timezone.utc)
|
||||
total_failed = 0
|
||||
|
||||
try:
|
||||
for scenario in scenarios:
|
||||
for number, scenario in enumerate(scenarios, 1):
|
||||
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)
|
||||
table, failed = await build(client, settings.llm_model_caller, scenario,
|
||||
existing or {}, force, gate)
|
||||
total_failed += failed
|
||||
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 ""))
|
||||
mark = f", не получено: {failed}" if failed else ""
|
||||
print(f" [{number}/{len(scenarios)}] {scenario.id}{mark}", flush=True)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
print("\nГотово. Занятие теперь идёт без сети: звонящий читает таблицу с диска.")
|
||||
spent = (datetime.now(timezone.utc) - started).total_seconds()
|
||||
print(f"\nГотово за {spent / 60:.1f} мин. Занятие идёт без сети: "
|
||||
"звонящий читает таблицу с диска.")
|
||||
if total_failed:
|
||||
print(f"Не получено блоков: {total_failed}. Повторный запуск допишет только их.")
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
122
backend/tests/test_pregen.py
Normal file
122
backend/tests/test_pregen.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Предгенерация офлайн-таблиц: разбор ответа и планирование работы.
|
||||
|
||||
Сеть здесь не нужна и не используется: проверяется то, что ломается тихо —
|
||||
разбор JSON от рассуждающей модели и решение «что ещё осталось сгенерировать».
|
||||
Сама генерация запускается руками там, где есть провайдер (tasks/lct-19).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.scenarios.loader import load_file
|
||||
from scripts.pregenerate import parse, tasks_for
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
||||
|
||||
# ── разбор ответа модели ──
|
||||
|
||||
|
||||
def test_plain_json_is_parsed():
|
||||
assert parse('{"panic": "Горим!", "calm": "У нас пожар."}') == {
|
||||
"panic": "Горим!",
|
||||
"calm": "У нас пожар.",
|
||||
}
|
||||
|
||||
|
||||
def test_json_in_markdown_fence_is_parsed():
|
||||
"""Рассуждающие модели любят обернуть ответ в ограду."""
|
||||
answer = 'Вот реплики:\n```json\n{"panic": "Горим!"}\n```\nГотово.'
|
||||
assert parse(answer) == {"panic": "Горим!"}
|
||||
|
||||
|
||||
def test_broken_json_gives_nothing_not_a_crash():
|
||||
"""Пустой разбор — это повтор запроса, а не падение прогона на середине."""
|
||||
assert parse("извините, не могу") == {}
|
||||
assert parse('{"panic": ') == {}
|
||||
|
||||
|
||||
def test_empty_lines_are_dropped():
|
||||
assert parse('{"panic": " ", "calm": "Пожар."}') == {"calm": "Пожар."}
|
||||
|
||||
|
||||
def test_non_string_values_are_dropped():
|
||||
assert parse('{"panic": ["а", "б"], "calm": "Пожар."}') == {"calm": "Пожар."}
|
||||
|
||||
|
||||
# ── планирование ──
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scenario():
|
||||
return load_file(LIBRARY / "tickets" / "t03-1-fire-private-house.yaml", LIBRARY)
|
||||
|
||||
|
||||
def test_empty_table_needs_every_fact(scenario):
|
||||
todo = tasks_for(scenario, {}, force=False)
|
||||
sections = {section for section, _, _ in todo}
|
||||
assert "reveal" in sections and "repeat" in sections and "fillers" in sections
|
||||
|
||||
|
||||
def test_refinable_fact_gets_its_own_section(scenario):
|
||||
"""Без секции `refine` офлайн-звонящий на уточняющий вопрос ответит
|
||||
заготовкой и уточнённого адреса не назовёт (lct-35)."""
|
||||
todo = tasks_for(scenario, {}, force=False)
|
||||
refine = [key for section, key, _ in todo if section == "refine"]
|
||||
assert refine == ["f_address"]
|
||||
|
||||
|
||||
def test_fact_without_refinement_has_no_refine_task(scenario):
|
||||
todo = tasks_for(scenario, {}, force=False)
|
||||
refine = {key for section, key, _ in todo if section == "refine"}
|
||||
assert "f_what_burns" not in refine
|
||||
|
||||
|
||||
def test_filled_table_needs_nothing(scenario):
|
||||
"""Прерванный прогон продолжается с места остановки, а не с начала."""
|
||||
from app.dialog.caller import MOOD_WORDS
|
||||
|
||||
moods = {mood.value: "реплика" for mood in MOOD_WORDS}
|
||||
table = {
|
||||
"reveal": {fact.id: dict(moods) for fact in scenario.facts},
|
||||
"repeat": {fact.id: dict(moods) for fact in scenario.facts},
|
||||
"refine": {fact.id: dict(moods) for fact in scenario.facts if fact.refined},
|
||||
"fillers": {mood.value: ["раз", "два", "три"] for mood in MOOD_WORDS},
|
||||
}
|
||||
assert tasks_for(scenario, table, force=False) == []
|
||||
|
||||
|
||||
def test_partially_filled_table_asks_only_for_the_rest(scenario):
|
||||
from app.dialog.caller import MOOD_WORDS
|
||||
|
||||
moods = {mood.value: "реплика" for mood in MOOD_WORDS}
|
||||
table = {"reveal": {fact.id: dict(moods) for fact in scenario.facts}}
|
||||
sections = {section for section, _, _ in tasks_for(scenario, table, force=False)}
|
||||
assert "reveal" not in sections
|
||||
assert "repeat" in sections
|
||||
|
||||
|
||||
def test_missing_single_mood_reopens_the_block(scenario):
|
||||
"""Блок считается готовым только когда есть все настроения: иначе
|
||||
звонящий молчит ровно в том состоянии, которого не хватает."""
|
||||
from app.dialog.caller import MOOD_WORDS
|
||||
|
||||
moods = {mood.value: "реплика" for mood in MOOD_WORDS}
|
||||
moods.pop("aggressive")
|
||||
table = {"reveal": {fact.id: dict(moods) for fact in scenario.facts}}
|
||||
reveal = [key for section, key, _ in tasks_for(scenario, table, force=False) if section == "reveal"]
|
||||
assert reveal, "неполный блок не попал в работу"
|
||||
|
||||
|
||||
def test_force_regenerates_everything(scenario):
|
||||
from app.dialog.caller import MOOD_WORDS
|
||||
|
||||
moods = {mood.value: "реплика" for mood in MOOD_WORDS}
|
||||
table = {
|
||||
"reveal": {fact.id: dict(moods) for fact in scenario.facts},
|
||||
"repeat": {fact.id: dict(moods) for fact in scenario.facts},
|
||||
"refine": {fact.id: dict(moods) for fact in scenario.facts if fact.refined},
|
||||
"fillers": {mood.value: ["раз", "два", "три"] for mood in MOOD_WORDS},
|
||||
}
|
||||
assert tasks_for(scenario, table, force=True)
|
||||
|
|
@ -88,3 +88,59 @@ async def test_caller_never_speaks_an_unrevealed_fact(caller, slots):
|
|||
for fact in SCENARIO.facts:
|
||||
if fact.id not in revealed:
|
||||
assert fact.value not in text, f"выдал «{fact.value}» на «{question}»"
|
||||
|
||||
|
||||
def test_refined_line_comes_from_the_table(tmp_path, monkeypatch):
|
||||
"""Офлайн-звонящий должен поправляться уточнённым адресом, а не отвечать
|
||||
заготовкой: секцию `refine` пишет `make pregen` (tasks/lct-19, lct-35)."""
|
||||
import yaml as _yaml
|
||||
|
||||
from app.dialog import tree as tree_module
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
scenario = Scenario.model_validate(
|
||||
{
|
||||
"id": "refine-offline",
|
||||
"title": "Проверка уточнения офлайн",
|
||||
"type": "fire",
|
||||
"level": "L3",
|
||||
"persona": {"base": "calm", "arc": [{"stage": "registration", "mood": "calm"}]},
|
||||
"first_line": "Горим!",
|
||||
"facts": [
|
||||
{
|
||||
"id": "f_address",
|
||||
"value": "улица Станционная, дом 28",
|
||||
"refined": "Королёв, улица Станционная, дом 28",
|
||||
"reveal_on": {"question": "q_address"},
|
||||
"refine_on": "q_city",
|
||||
}
|
||||
],
|
||||
"checklist": [
|
||||
{"id": "q_address", "question": "Назовите адрес", "fact": "f_address"},
|
||||
{"id": "q_city", "question": "Это точно Москва город?"},
|
||||
],
|
||||
}
|
||||
)
|
||||
table = {
|
||||
"scenario": scenario.id,
|
||||
"first_line": "Горим!",
|
||||
"reveal": {"f_address": {"calm": "Станционная, двадцать восемь."}},
|
||||
"refine": {"f_address": {"calm": "Нет, это Королёв, Станционная, двадцать восемь."}},
|
||||
"repeat": {},
|
||||
"fillers": {"calm": ["Не расслышал."]},
|
||||
}
|
||||
monkeypatch.setattr(tree_module, "LIBRARY", tmp_path)
|
||||
(tmp_path / f"{scenario.id}.yaml").write_text(
|
||||
_yaml.safe_dump(table, allow_unicode=True), encoding="utf-8"
|
||||
)
|
||||
|
||||
caller = tree_module.TreeCaller(scenario.id)
|
||||
slots = SlotMachine(scenario, StemEmbedder(), floor=0.5)
|
||||
persona = PersonaState(scenario.persona)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(caller.reply(slots.hear("Назовите адрес"), persona, slots))
|
||||
line = asyncio.run(caller.reply(slots.hear("Это точно Москва город?"), persona, slots))
|
||||
assert "Королёв" in line.text, f"звонящий не поправился: {line.text}"
|
||||
assert caller.misses == 0, "реплика ушла в заготовки, хотя в таблице она есть"
|
||||
|
|
|
|||
Loading…
Reference in a new issue