lct-hack/backend/tests/test_refinement.py

169 lines
7 KiB
Python
Raw Normal View History

feat: уточнение адреса — место происшествия не равно адресу заявителя (lct-35) Главный предмет проверки в билетах заказчика — адрес: названный заявителем часто неверен, а настоящий добывается переспросом («ул. Станционная, 28» оказывается Королёвом). Памятка АРМ-112 называет это обычным делом. До этой правки оценка работала наоборот: ground_truth.address сравнивался с единственным значением факта, поэтому курсант, правильно переспросивший и записавший настоящий адрес, получал расхождение с эталоном, а записавший ориентир — зачёт. - Схема: у факта появились refined и refine_on, задаются только вместе. - Слот-автомат различает уточнение и повтор: повтор раздражает звонящего, уточнение — нет, оператор спросил о другом и получил другое. - Звонящий поправляется отдельной репликой, а не повторяет прежнее значение; офлайн-таблица получила секцию refine. - Оценка: метрика address_refined (E1, опрос). Сделана отдельной, а не правкой метрики address: «записал не тот адрес» и «не спросил» — разные навыки и разные компетенции в радаре. - Общие пункты чек-листа подключаются по требованию сценария: формулировки в checklists/common.yaml, сценарий объявляет пункт с тем же id без текста. Добавлять «уточните адрес» во все сценарии нельзя — неотработанный пункт штрафует за вопрос, которого сценарий не требовал. - scenarios/fire-private-house-l3.yaml — первый сценарий из библиотеки заказчика (билет 3, вызов 1). Риск механики проверен на живой модели: «Назовите адрес» и «Это точно Москва?» эмбеддинги не путают, тест это фиксирует. 125 тестов зелёных (12 новых), make typecheck чистый.
2026-09-19 20:26:49 +03:00
"""Уточнение адреса: место происшествия ≠ адрес заявителя.
Пункты приёмки lct-35. Логика — на детерминированной заглушке эмбеддингов;
что живая модель отличает уточняющий вопрос от обычного, проверяется отдельно
и пропускается без скачанной модели.
"""
from pathlib import Path
import pytest
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import evaluate
from app.session.timers import SessionTimers
from tests.test_slots import StemEmbedder
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
MODEL = Path(__file__).resolve().parents[1] / "models" / "e5-small"
SCENARIO = Scenario.model_validate(
{
"id": "refine-test",
"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": "Это точно Москва город?"},
],
}
)
@pytest.fixture
def slots():
return SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
def test_first_answer_is_the_address_the_caller_named(slots):
turn = slots.hear("Назовите адрес")
assert turn.revealed == ["f_address"]
assert slots.value_of("f_address") == "улица Станционная, дом 28"
def test_refinement_replaces_the_value(slots):
slots.hear("Назовите адрес")
turn = slots.hear("Это точно Москва город?")
assert turn.refined == ["f_address"]
assert slots.value_of("f_address") == "Королёв, улица Станционная, дом 28"
def test_refinement_is_not_a_repeat(slots):
"""Оператор спросил о другом и получил другое — раздражать звонящего
за правильный вопрос нельзя."""
slots.hear("Назовите адрес")
turn = slots.hear("Это точно Москва город?")
assert turn.repeated == [], "уточнение засчитано повтором"
def test_caller_says_the_refined_value(slots):
persona = PersonaState(SCENARIO.persona)
caller = TemplateCaller()
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}"
def test_refining_before_asking_still_reveals(slots):
"""Оператор начал с уточнения — факт всё равно считается раскрытым,
иначе звонящий промолчит о том, что сам только что назвал."""
turn = slots.hear("Это точно Москва город?")
assert "f_address" in turn.revealed
assert slots.value_of("f_address") == "Королёв, улица Станционная, дом 28"
def test_refinement_happens_once(slots):
slots.hear("Это точно Москва город?")
again = slots.hear("Это точно Москва город?")
assert again.refined == []
# ── сценарий из библиотеки и оценка ──
@pytest.fixture(scope="module")
def library_scenario():
return load_file(LIBRARY / "fire-private-house-l3.yaml", LIBRARY)
def test_library_scenario_expects_the_refined_address(library_scenario):
"""Эталон — уточнённый адрес: записавший первичный отправит расчёт
не в тот регион."""
assert "Королёв" in library_scenario.ground_truth.address
assert any(fact.refine_on for fact in library_scenario.facts)
def test_common_checklist_item_is_opt_in(library_scenario):
"""Формулировки общего пункта подставились из common.yaml, а в сценарий
без него он не попал."""
item = next(i for i in library_scenario.checklist if i.id == "q_address_check")
assert item.question, "текст общего пункта не подставился"
assert len(item.examples) >= 5
other = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
assert "q_address_check" not in {i.id for i in other.checklist}
def _score(scenario, refined: list[str]):
from app.domain.kio import KIO
timers = SessionTimers()
timers.on_event("call.incoming", now=0.0)
timers.on_event("call.answer", now=5.0)
return evaluate(
scenario=scenario,
kio=KIO(address=scenario.ground_truth.address),
timers=timers,
revealed_facts=[fact.id for fact in scenario.facts],
refined_facts=refined,
)
def test_not_refining_is_an_e1(library_scenario):
result = _score(library_scenario, refined=[])
metric = next(m for m in result.metrics if m.key == "address_refined")
assert not metric.passed
assert any(finding.code.value == "E1" for finding in result.findings)
def test_refining_passes(library_scenario):
result = _score(library_scenario, refined=["f_address"])
metric = next(m for m in result.metrics if m.key == "address_refined")
assert metric.passed
def test_scenario_without_refinement_has_no_such_metric():
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
result = _score(scenario, refined=[])
assert not [m for m in result.metrics if m.key == "address_refined"]
@pytest.mark.skipif(
not (MODEL / "model_quantized.onnx").exists(), reason="нет модели эмбеддингов — make models"
)
def test_real_model_tells_the_two_questions_apart(library_scenario):
"""Главный риск механики: «назовите адрес» и «это точно Москва?» — оба про
адрес, и модель могла бы засчитать их одному пункту."""
from app.dialog.embeddings import E5Embedder
machine = SlotMachine(library_scenario, E5Embedder(MODEL))
assert machine.hear("Назовите адрес").matched == ["q_address"]
assert machine.hear("Это точно Москва?").matched == ["q_address_check"]