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 чистый.
This commit is contained in:
parent
7212251112
commit
842a333d16
11 changed files with 381 additions and 8 deletions
|
|
@ -69,6 +69,16 @@ REVEAL: dict[Mood, str] = {
|
||||||
Mood.CONFUSED: "Так... {fact}... кажется.",
|
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] = {
|
REPEAT: dict[Mood, str] = {
|
||||||
Mood.PANIC: "Я же сказал — {fact}! Записывайте!",
|
Mood.PANIC: "Я же сказал — {fact}! Записывайте!",
|
||||||
|
|
@ -94,7 +104,11 @@ class TemplateCaller:
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for fact_id in turn.revealed:
|
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:
|
for fact_id in turn.repeated:
|
||||||
parts.append(REPEAT[mood].format(fact=facts[fact_id]))
|
parts.append(REPEAT[mood].format(fact=facts[fact_id]))
|
||||||
|
|
||||||
|
|
@ -139,7 +153,11 @@ class LlmCaller:
|
||||||
mood = persona.remember()
|
mood = persona.remember()
|
||||||
|
|
||||||
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
|
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]
|
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
|
||||||
|
|
||||||
system = _prompt("caller.md").format(
|
system = _prompt("caller.md").format(
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ class TurnResult:
|
||||||
matched: list[str] = field(default_factory=list) # пункты чек-листа
|
matched: list[str] = field(default_factory=list) # пункты чек-листа
|
||||||
revealed: list[str] = field(default_factory=list) # факты, раскрытые впервые
|
revealed: list[str] = field(default_factory=list) # факты, раскрытые впервые
|
||||||
repeated: 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)
|
scores: dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -95,8 +96,16 @@ class SlotMachine:
|
||||||
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
|
if question and not fact.hidden and fact.id not in self._reveals.get(question, []):
|
||||||
self._reveals.setdefault(question, []).append(fact.id)
|
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.asked: list[str] = []
|
||||||
self.revealed: list[str] = []
|
self.revealed: list[str] = []
|
||||||
|
self.refined: list[str] = []
|
||||||
|
|
||||||
# ── реплика оператора ──
|
# ── реплика оператора ──
|
||||||
|
|
||||||
|
|
@ -149,6 +158,18 @@ class SlotMachine:
|
||||||
else:
|
else:
|
||||||
self.revealed.append(fact_id)
|
self.revealed.append(fact_id)
|
||||||
result.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
|
return result
|
||||||
|
|
||||||
def reveal_by_approach(self, fact_id: str) -> bool:
|
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]:
|
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]:
|
def unasked(self) -> list[ChecklistItem]:
|
||||||
"""Неотработанные пункты по порядку чек-листа — источник подсказки."""
|
"""Неотработанные пункты по порядку чек-листа — источник подсказки."""
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,11 @@ class TreeCaller:
|
||||||
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
for fact_id in turn.revealed:
|
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:
|
for fact_id in turn.repeated:
|
||||||
parts.append(self._line("repeat", fact_id, mood) or "")
|
parts.append(self._line("repeat", fact_id, mood) or "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,23 @@ def _common_not_questions(root: Path) -> list[str]:
|
||||||
return list(_read_yaml(common).get("not_questions", []))
|
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:
|
def load_file(path: Path, root: Path) -> Scenario:
|
||||||
raw = _read_yaml(path)
|
raw = _read_yaml(path)
|
||||||
raw["not_questions"] = _common_not_questions(root) + list(raw.get("not_questions", []))
|
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")
|
extends = raw.get("extends")
|
||||||
if extends:
|
if extends:
|
||||||
base_path = root / extends
|
base_path = root / extends
|
||||||
if not base_path.exists():
|
if not base_path.exists():
|
||||||
raise ScenarioError(f"{path.name}: чек-лист {extends} не найден")
|
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"] = [
|
raw["checklist"] = [
|
||||||
item.model_dump(exclude_none=True)
|
item.model_dump(exclude_none=True) for item in _merge_checklist(base, own)
|
||||||
for item in _merge_checklist(base, raw.get("checklist", []))
|
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -118,13 +137,19 @@ def load_file(path: Path, root: Path) -> Scenario:
|
||||||
if not item.question:
|
if not item.question:
|
||||||
raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса")
|
raise ScenarioError(f"{path.name}: у пункта {item.id} нет текста вопроса")
|
||||||
|
|
||||||
|
item_ids = {item.id for item in scenario.checklist}
|
||||||
for fact in scenario.facts:
|
for fact in scenario.facts:
|
||||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
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(
|
raise ScenarioError(
|
||||||
f"{path.name}: факт {fact.id} раскрывается вопросом {question_id}, "
|
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)
|
return _derive_ground_truth(scenario)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,26 @@ class RevealOn(Strict):
|
||||||
|
|
||||||
|
|
||||||
class Fact(Strict):
|
class Fact(Strict):
|
||||||
|
"""Факт, который оператор должен добыть.
|
||||||
|
|
||||||
|
`refined` — второе, настоящее значение. Нужно там, где заявитель называет
|
||||||
|
ориентир или адрес, по которому стоит сам: «ул. Станционная, 28» оказывается
|
||||||
|
Королёвом, «дом с библиотекой №193» — домом 11 по улице Грина. Памятка
|
||||||
|
заказчика называет это обычным делом, а не краем сценария, и в билетах такой
|
||||||
|
вызов не один (docs/spec/TICKETS.md).
|
||||||
|
|
||||||
|
Пока оператор не переспросил, звонящий говорит `value`; после уточняющего
|
||||||
|
вопроса — `refined`, и эталон сверяется уже с ним.
|
||||||
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
value: str
|
value: str
|
||||||
hidden: bool = False
|
hidden: bool = False
|
||||||
reveal_on: RevealOn | None = None
|
reveal_on: RevealOn | None = None
|
||||||
|
refined: str | None = None
|
||||||
|
refine_on: str | None = Field(
|
||||||
|
default=None, description="Пункт чек-листа, уточняющий этот факт"
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def hidden_needs_condition(self):
|
def hidden_needs_condition(self):
|
||||||
|
|
@ -58,6 +74,15 @@ class Fact(Strict):
|
||||||
)
|
)
|
||||||
return self
|
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):
|
class ChecklistItem(Strict):
|
||||||
"""Пункт эталонного опроса.
|
"""Пункт эталонного опроса.
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ def evaluate(
|
||||||
kio: KIO,
|
kio: KIO,
|
||||||
timers: SessionTimers,
|
timers: SessionTimers,
|
||||||
revealed_facts: list[str] | None,
|
revealed_facts: list[str] | None,
|
||||||
|
refined_facts: list[str] | None = None,
|
||||||
end_reason: CallEndReason | None = None,
|
end_reason: CallEndReason | None = None,
|
||||||
bounced_fields: list[str] | None = None,
|
bounced_fields: list[str] | None = None,
|
||||||
) -> GostResult:
|
) -> GostResult:
|
||||||
|
|
@ -222,6 +223,23 @@ def evaluate(
|
||||||
finding=f"Карточка ушла в ДДС {actual}, верная — {truth.dds.value}",
|
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 ──
|
# ── карточка, E5 ──
|
||||||
if truth.address:
|
if truth.address:
|
||||||
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
|
written = kio.address or " ".join(filter(None, [kio.street, kio.building]))
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
||||||
"dds_chain": (ErrorCode.E6, Competency.CARD),
|
"dds_chain": (ErrorCode.E6, Competency.CARD),
|
||||||
"callback": (ErrorCode.E3, Competency.INTAKE),
|
"callback": (ErrorCode.E3, Competency.INTAKE),
|
||||||
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
|
"checklist_completeness": (ErrorCode.E1, Competency.INTERVIEW),
|
||||||
|
"address_refined": (ErrorCode.E1, Competency.INTERVIEW),
|
||||||
"interview_time": (ErrorCode.E3, Competency.NORMS),
|
"interview_time": (ErrorCode.E3, Competency.NORMS),
|
||||||
"incident_signs": (ErrorCode.E2, Competency.ROUTING),
|
"incident_signs": (ErrorCode.E2, Competency.ROUTING),
|
||||||
"incident_type": (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] = {
|
METRIC_WEIGHTS: dict[str, float] = {
|
||||||
"checklist_completeness": 4.0,
|
"checklist_completeness": 4.0,
|
||||||
|
"address_refined": 2.0,
|
||||||
"incident_signs": 2.0,
|
"incident_signs": 2.0,
|
||||||
"incident_type": 2.0,
|
"incident_type": 2.0,
|
||||||
"dds_choice": 2.0,
|
"dds_choice": 2.0,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ async def finish(session_id: UUID, state) -> None:
|
||||||
kio=state.kio,
|
kio=state.kio,
|
||||||
timers=state.timers,
|
timers=state.timers,
|
||||||
revealed_facts=[fact.id for fact in state.slots.revealed_facts()] if state.slots else None,
|
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,
|
end_reason=state.end_reason,
|
||||||
bounced_fields=state.bounced_fields,
|
bounced_fields=state.bounced_fields,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
169
backend/tests/test_refinement.py
Normal file
169
backend/tests/test_refinement.py
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -17,3 +17,20 @@ not_questions:
|
||||||
- "Выходите из квартиры"
|
- "Выходите из квартиры"
|
||||||
- "Не кладите трубку"
|
- "Не кладите трубку"
|
||||||
- "Хорошо, записываю"
|
- "Хорошо, записываю"
|
||||||
|
|
||||||
|
# Пункты, которые не принадлежат ни одному классификатору. Сценарий подключает
|
||||||
|
# нужный, объявив пункт с тем же `id` и без текста, — формулировки подставятся
|
||||||
|
# отсюда. Насильно во все сценарии они не добавляются: неотработанный пункт
|
||||||
|
# штрафует курсанта отметкой E1 за вопрос, которого сценарий не требовал.
|
||||||
|
checklist:
|
||||||
|
- id: q_address_check
|
||||||
|
question: "Уточните: происшествие по этому адресу, вы сейчас там?"
|
||||||
|
examples:
|
||||||
|
- "Это точно Москва?"
|
||||||
|
- "Вы сами находитесь по этому адресу?"
|
||||||
|
- "Происшествие там же, где вы?"
|
||||||
|
- "Какой город?"
|
||||||
|
- "Это в черте города?"
|
||||||
|
- "Уточните адрес происшествия"
|
||||||
|
- "Вы смотрите на это или вам рассказали?"
|
||||||
|
- "Адрес происшествия и ваш адрес совпадают?"
|
||||||
|
|
|
||||||
62
scenarios/fire-private-house-l3.yaml
Normal file
62
scenarios/fire-private-house-l3.yaml
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue