diff --git a/backend/app/dialog/caller.py b/backend/app/dialog/caller.py index a5a9021..73d50e9 100644 --- a/backend/app/dialog/caller.py +++ b/backend/app/dialog/caller.py @@ -69,6 +69,16 @@ REVEAL: dict[Mood, str] = { Mood.CONFUSED: "Так... {fact}... кажется.", } +#: Уточнение: оператор переспросил, и выясняется, что адрес был не тот. +#: Это не повтор — звонящий не раздражается, он поправляется. +REFINE: dict[Mood, str] = { + Mood.PANIC: "Нет, не там! {fact}!", + Mood.WORRIED: "Ой, нет... {fact}.", + Mood.CALM: "Уточню: {fact}.", + Mood.AGGRESSIVE: "Да я же про другое! {fact}!", + Mood.CONFUSED: "А... нет, кажется {fact}...", +} + #: Повторный вопрос по уже сказанному. REPEAT: dict[Mood, str] = { Mood.PANIC: "Я же сказал — {fact}! Записывайте!", @@ -94,7 +104,11 @@ class TemplateCaller: parts: list[str] = [] for fact_id in turn.revealed: - parts.append(REVEAL[mood].format(fact=facts[fact_id])) + template = REFINE if fact_id in turn.refined else REVEAL + parts.append(template[mood].format(fact=facts[fact_id])) + for fact_id in turn.refined: + if fact_id not in turn.revealed: + parts.append(REFINE[mood].format(fact=facts[fact_id])) for fact_id in turn.repeated: parts.append(REPEAT[mood].format(fact=facts[fact_id])) @@ -139,7 +153,11 @@ class LlmCaller: 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] + say_now = [ + facts[fact_id] + for fact_id in [*turn.revealed, *turn.refined] + 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( diff --git a/backend/app/dialog/slots.py b/backend/app/dialog/slots.py index 953256b..f3455c2 100644 --- a/backend/app/dialog/slots.py +++ b/backend/app/dialog/slots.py @@ -49,6 +49,7 @@ class TurnResult: matched: list[str] = field(default_factory=list) # пункты чек-листа revealed: list[str] = field(default_factory=list) # факты, раскрытые впервые repeated: list[str] = field(default_factory=list) # факты, спрошенные повторно + refined: list[str] = field(default_factory=list) # факты, уточнённые переспросом scores: dict[str, float] = field(default_factory=dict) @property @@ -95,8 +96,16 @@ class SlotMachine: if question and not fact.hidden and fact.id not in self._reveals.get(question, []): self._reveals.setdefault(question, []).append(fact.id) + # Какой пункт чек-листа какой факт уточняет: «это точно Москва?» меняет + # адрес заявителя на адрес происшествия (docs/spec/TICKETS.md). + self._refines: dict[str, list[str]] = {} + for fact in scenario.facts: + if fact.refine_on: + self._refines.setdefault(fact.refine_on, []).append(fact.id) + self.asked: list[str] = [] self.revealed: list[str] = [] + self.refined: list[str] = [] # ── реплика оператора ── @@ -149,6 +158,18 @@ class SlotMachine: else: self.revealed.append(fact_id) result.revealed.append(fact_id) + + # Уточнение — не повтор: оператор спросил о другом и получил другое. + for fact_id in self._refines.get(item.id, []): + if fact_id in self.refined: + continue + self.refined.append(fact_id) + result.refined.append(fact_id) + if fact_id not in self.revealed: + # Уточнили раньше, чем спросили: факт считается раскрытым, + # иначе звонящий промолчит о том, что только что назвал. + self.revealed.append(fact_id) + result.revealed.append(fact_id) return result def reveal_by_approach(self, fact_id: str) -> bool: @@ -168,9 +189,20 @@ class SlotMachine: # ── что видят другие ── + def value_of(self, fact_id: str) -> str: + """Значение факта с учётом уточнения: до переспроса заявитель называет + ориентир, после — настоящий адрес.""" + fact = self._facts[fact_id] + if fact_id in self.refined and fact.refined: + return fact.refined + return fact.value + def revealed_facts(self) -> list[Fact]: """Единственное, что уходит в контекст звонящего.""" - return [self._facts[fact_id] for fact_id in self.revealed] + return [ + self._facts[fact_id].model_copy(update={"value": self.value_of(fact_id)}) + for fact_id in self.revealed + ] def unasked(self) -> list[ChecklistItem]: """Неотработанные пункты по порядку чек-листа — источник подсказки.""" diff --git a/backend/app/dialog/tree.py b/backend/app/dialog/tree.py index 91a184f..a78be54 100644 --- a/backend/app/dialog/tree.py +++ b/backend/app/dialog/tree.py @@ -64,7 +64,11 @@ class TreeCaller: parts: list[str] = [] for fact_id in turn.revealed: - parts.append(self._line("reveal", fact_id, mood) or "") + section = "refine" if fact_id in turn.refined else "reveal" + parts.append(self._line(section, fact_id, mood) or "") + for fact_id in turn.refined: + if fact_id not in turn.revealed: + parts.append(self._line("refine", fact_id, mood) or "") for fact_id in turn.repeated: parts.append(self._line("repeat", fact_id, mood) or "") diff --git a/backend/app/scenarios/loader.py b/backend/app/scenarios/loader.py index 4c2c10a..da8f241 100644 --- a/backend/app/scenarios/loader.py +++ b/backend/app/scenarios/loader.py @@ -79,6 +79,23 @@ def _common_not_questions(root: Path) -> list[str]: return list(_read_yaml(common).get("not_questions", [])) +def _common_checklist(root: Path, wanted: set[str]) -> list[dict]: + """Общие пункты опроса — по требованию сценария, а не всем подряд. + + В `common.yaml` лежат формулировки пунктов, которые не принадлежат ни одному + классификатору: проверка адреса, контакты заявителя. Сценарий подключает их, + объявив пункт с тем же `id` и без текста, — текст и примеры подставятся сюда. + Насильно добавлять их во все сценарии нельзя: непрочитанный пункт чек-листа + штрафует курсанта отметкой E1 за вопрос, которого сценарий не требовал. + """ + common = root / COMMON + if not common.exists(): + return [] + return [ + item for item in _read_yaml(common).get("checklist", []) if item.get("id") in wanted + ] + + def load_file(path: Path, root: Path) -> Scenario: raw = _read_yaml(path) raw["not_questions"] = _common_not_questions(root) + list(raw.get("not_questions", [])) @@ -91,15 +108,17 @@ def load_file(path: Path, root: Path) -> Scenario: "и руками не пишется — иначе факты и эталон разъедутся" ) + own = raw.get("checklist", []) + base = _common_checklist(root, {item.get("id") for item in own}) extends = raw.get("extends") if extends: base_path = root / extends if not base_path.exists(): raise ScenarioError(f"{path.name}: чек-лист {extends} не найден") - base = _read_yaml(base_path).get("checklist", []) + base = base + _read_yaml(base_path).get("checklist", []) + if base: raw["checklist"] = [ - item.model_dump(exclude_none=True) - for item in _merge_checklist(base, raw.get("checklist", [])) + item.model_dump(exclude_none=True) for item in _merge_checklist(base, own) ] try: @@ -118,13 +137,19 @@ def load_file(path: Path, root: Path) -> Scenario: if not item.question: raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса") + item_ids = {item.id for item in scenario.checklist} for fact in scenario.facts: question_id = fact.reveal_on.question if fact.reveal_on else None - if question_id and question_id not in {item.id for item in scenario.checklist}: + if question_id and question_id not in item_ids: raise ScenarioError( f"{path.name}: факт {fact.id} раскрывается вопросом {question_id}, " "которого нет в чек-листе" ) + if fact.refine_on and fact.refine_on not in item_ids: + raise ScenarioError( + f"{path.name}: факт {fact.id} уточняется вопросом {fact.refine_on}, " + "которого нет в чек-листе" + ) return _derive_ground_truth(scenario) diff --git a/backend/app/scenarios/schema.py b/backend/app/scenarios/schema.py index ac69deb..97bef07 100644 --- a/backend/app/scenarios/schema.py +++ b/backend/app/scenarios/schema.py @@ -44,10 +44,26 @@ class RevealOn(Strict): class Fact(Strict): + """Факт, который оператор должен добыть. + + `refined` — второе, настоящее значение. Нужно там, где заявитель называет + ориентир или адрес, по которому стоит сам: «ул. Станционная, 28» оказывается + Королёвом, «дом с библиотекой №193» — домом 11 по улице Грина. Памятка + заказчика называет это обычным делом, а не краем сценария, и в билетах такой + вызов не один (docs/spec/TICKETS.md). + + Пока оператор не переспросил, звонящий говорит `value`; после уточняющего + вопроса — `refined`, и эталон сверяется уже с ним. + """ + id: str value: str hidden: bool = False reveal_on: RevealOn | None = None + refined: str | None = None + refine_on: str | None = Field( + default=None, description="Пункт чек-листа, уточняющий этот факт" + ) @model_validator(mode="after") def hidden_needs_condition(self): @@ -58,6 +74,15 @@ class Fact(Strict): ) return self + @model_validator(mode="after") + def refinement_needs_both_halves(self): + if bool(self.refined) != bool(self.refine_on): + raise ValueError( + f"факт {self.id}: refined и refine_on задаются вместе — " + "иначе уточнение либо нечем вызвать, либо нечего сказать" + ) + return self + class ChecklistItem(Strict): """Пункт эталонного опроса. diff --git a/backend/app/scoring/gost.py b/backend/app/scoring/gost.py index fd8d141..544a976 100644 --- a/backend/app/scoring/gost.py +++ b/backend/app/scoring/gost.py @@ -111,6 +111,7 @@ def evaluate( kio: KIO, timers: SessionTimers, revealed_facts: list[str] | None, + refined_facts: list[str] | None = None, end_reason: CallEndReason | None = None, bounced_fields: list[str] | None = None, ) -> GostResult: @@ -222,6 +223,23 @@ def evaluate( finding=f"Карточка ушла в ДДС {actual}, верная — {truth.dds.value}", ) + # ── подтверждение места происшествия, E1 ── + # + # Адрес заявителя не всегда адрес происшествия: он называет тот, где стоит + # сам, или ориентир вместо дома. В билетах заказчика это основной приём + # усложнения (docs/spec/TICKETS.md), и оператор обязан переспросить. + refinable = [fact.id for fact in scenario.facts if fact.refine_on] + if refinable and refined_facts is not None: + confirmed = [fact_id for fact_id in refinable if fact_id in refined_facts] + build.add( + "address_refined", "Место происшествия подтверждено", + f"уточнено {len(confirmed)} из {len(refinable)}", "уточнены все", + passed=len(confirmed) == len(refinable), + ref="памятка АРМ-112: адрес заявителя не всегда адрес происшествия", + finding="Оператор не переспросил про место происшествия — записан адрес," + " который назвал заявитель", + ) + # ── карточка, E5 ── if truth.address: written = kio.address or " ".join(filter(None, [kio.street, kio.building])) diff --git a/backend/app/scoring/taxonomy.py b/backend/app/scoring/taxonomy.py index cb0ba92..cd45e9e 100644 --- a/backend/app/scoring/taxonomy.py +++ b/backend/app/scoring/taxonomy.py @@ -12,6 +12,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { "dds_chain": (ErrorCode.E6, Competency.CARD), "callback": (ErrorCode.E3, Competency.INTAKE), "checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW), + "address_refined": (ErrorCode.E1, Competency.INTERVIEW), "interview_time": (ErrorCode.E3, Competency.NORMS), "incident_signs": (ErrorCode.E2, Competency.ROUTING), "incident_type": (ErrorCode.E2, Competency.ROUTING), @@ -30,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = { #: тренажёр, поэтому он весит больше всего. METRIC_WEIGHTS: dict[str, float] = { "checklist_completeness": 4.0, + "address_refined": 2.0, "incident_signs": 2.0, "incident_type": 2.0, "dds_choice": 2.0, diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 5853cd5..eb9904a 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -31,6 +31,7 @@ async def finish(session_id: UUID, state) -> None: kio=state.kio, timers=state.timers, revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None, + refined_facts=list(state.slots.refined) if state.slots else None, end_reason=state.end_reason, bounced_fields=state.bounced_fields, ) diff --git a/backend/tests/test_refinement.py b/backend/tests/test_refinement.py new file mode 100644 index 0000000..5a116a9 --- /dev/null +++ b/backend/tests/test_refinement.py @@ -0,0 +1,169 @@ +"""Уточнение адреса: место происшествия ≠ адрес заявителя. + +Пункты приёмки 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"] diff --git a/scenarios/checklists/common.yaml b/scenarios/checklists/common.yaml index 5cc6b2d..53c947b 100644 --- a/scenarios/checklists/common.yaml +++ b/scenarios/checklists/common.yaml @@ -17,3 +17,20 @@ not_questions: - "Выходите из квартиры" - "Не кладите трубку" - "Хорошо, записываю" + +# Пункты, которые не принадлежат ни одному классификатору. Сценарий подключает +# нужный, объявив пункт с тем же `id` и без текста, — формулировки подставятся +# отсюда. Насильно во все сценарии они не добавляются: неотработанный пункт +# штрафует курсанта отметкой E1 за вопрос, которого сценарий не требовал. +checklist: + - id: q_address_check + question: "Уточните: происшествие по этому адресу, вы сейчас там?" + examples: + - "Это точно Москва?" + - "Вы сами находитесь по этому адресу?" + - "Происшествие там же, где вы?" + - "Какой город?" + - "Это в черте города?" + - "Уточните адрес происшествия" + - "Вы смотрите на это или вам рассказали?" + - "Адрес происшествия и ваш адрес совпадают?" diff --git a/scenarios/fire-private-house-l3.yaml b/scenarios/fire-private-house-l3.yaml new file mode 100644 index 0000000..d445c88 --- /dev/null +++ b/scenarios/fire-private-house-l3.yaml @@ -0,0 +1,62 @@ +# Билет 3, вызов 1 (docs/spec/TICKETS.md). Взят первым из библиотеки заказчика, +# потому что показывает приём, которого не было в наших сценариях: названный +# адрес существует и в Москве, и в области, и правильный узнаётся только +# переспросом. Механика — tasks/lct-35-address-refinement.md +id: fire-private-house-l3 +title: "Пожар в частном доме, адрес уточняется" +type: fire +level: L3 +topics: [fires, address] +modes: [training, exam, self] + +extends: checklists/fire.yaml + +signs: [жилой дом, частный дом, открытое пламя] + +persona: + base: worried + arc: + - { stage: registration, mood: worried } + - { stage: interview, mood: worried } + - { stage: dispatch, mood: calm } + +first_line: "Здравствуйте! У нас крыша горит, дом деревянный! Приезжайте!" + +facts: + - id: f_address + value: "улица Станционная, дом 28" + # Заявитель называет улицу так, будто это Москва. Настоящий адрес — + # подмосковный Королёв, и он всплывает только на уточняющий вопрос. + refined: "Московская область, город Королёв, улица Станционная, дом 28" + reveal_on: { question: q_address } + refine_on: q_address_check + - id: f_what_burns + value: "горит крыша, огонь открытый, дом деревянный" + reveal_on: { question: q_what_burns } + - id: f_people + value: "все вышли во двор, в доме никого нет" + reveal_on: { question: q_people } + - id: f_gas + value: "дом не газифицирован, газа нет" + reveal_on: { question: q_gas } + +checklist: + - { id: q_address, fact: f_address } + - { id: q_address_check } + - { id: q_what_burns, fact: f_what_burns } + - { id: q_people, fact: f_people } + - { id: q_gas, fact: f_gas } + +required_fields: + - address + - incident_type + - description + +ground_truth: + # Эталон — уточнённый адрес: записавший «улица Станционная, 28» отправит + # расчёт не в тот регион. + address: "Московская область, город Королёв, улица Станционная, дом 28" + victims: 0 + +tree: + pregenerated: false