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:
Ivan Gerasimov 2026-09-19 20:26:49 +03:00
commit 842a333d16
11 changed files with 381 additions and 8 deletions

View file

@ -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(

View file

@ -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]:
"""Неотработанные пункты по порядку чек-листа — источник подсказки."""

View file

@ -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 "")

View file

@ -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)

View file

@ -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):
"""Пункт эталонного опроса.

View file

@ -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]))

View file

@ -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,

View file

@ -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,
)