lct-hack/backend/app/dialog/director.py
Ivan Gerasimov 57f4c858b6 lct-22: пульт директив — преподаватель ведёт ситуацию
Мягкие директивы перекрывают дугу сценария и применяются со следующей
реплики: разговор не дёргается от того, что преподаватель что-то нажал.
Жёсткие правят ситуацию — обрыв связи рвёт звук тем же механизмом, что
перебивание, и запускает норматив обратного дозвона; второй пострадавший
правит эталон; неточный адрес снимает раскрытый факт, и оператор обязан
переспросить.

Своя копия сценария на занятие: директивы правят факты и эталон, а
сценарий был общим на библиотеку — правка в одной группе протекла бы во
все остальные. Тест проверяет, что библиотека не изменилась.

Свободный текст честно отказывает: офлайн-дерево предгенерировано,
произвольную фразу взять неоткуда, и преподаватель видит это на пульте.

Ни одна директива не трогает карточку курсанта — он управляет ситуацией,
а не работой обучаемого.

Занятие теперь собирается целиком и только потом регистрируется:
наблюдатель мог увидеть его без слот-автомата и звонящего.
2026-09-17 21:38:43 +03:00

86 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Директивы преподавателя: он ведёт ситуацию по ходу разговора.
«Управление ситуаций с рабочего места преподавателя» — дословное требование ТЗ,
понятое буквально (docs/product/CALL-SIM.md#преподаватель-режиссёр).
Мягкие директивы меняют подачу и живут до конца звонка, жёсткие правят факты
и таймеры. **Ни одна не трогает карточку курсанта:** преподаватель управляет
ситуацией, а не работой обучаемого.
"""
from dataclasses import dataclass
from app.domain.events import Mood
#: Мягкие директивы: только подача. Настроение берёт `PersonaState`.
SOFT = {
"panic_rises": "Паника нарастает",
"screaming": "Переходит на крик",
"turns_aggressive": "Переходит в агрессию",
"distracted": "Звонящий отвлёкся",
}
#: Жёсткие: правят факты, таймеры или ход звонка.
HARD = {
"line_dropped": "Связь обрывается",
"second_victim": "Появился второй пострадавший",
"address_wrong": "Адрес оказался неточным",
}
#: Что звонящий скажет следующей репликой по жёсткой директиве.
HARD_LINES = {
"second_victim": "Тут ещё женщина на площадке! Она не дышит! Быстрее!",
"address_wrong": "Стойте! Я перепутал, адрес другой! Запишите заново!",
}
@dataclass
class DirectiveResult:
applied: bool
#: Реплика, которую звонящий должен сказать сразу.
say: str | None = None
#: Оборвать связь: звук на полуслове, дальше обратный дозвон.
drop_line: bool = False
#: Директива требует сети (свободный текст без LLM).
needs_network: bool = False
def apply(state, directive: str) -> DirectiveResult:
"""Применить директиву к живому занятию."""
if directive in SOFT:
if state.persona is not None:
state.persona.directive = directive
return DirectiveResult(applied=True)
if directive == "line_dropped":
return DirectiveResult(applied=True, drop_line=True)
if directive == "second_victim":
# Правка эталона: теперь пострадавших на одного больше, и оценка
# карточки считается против нового числа.
truth = state.scenario.ground_truth if state.scenario else None
if truth is not None:
truth.victims = (truth.victims or 0) + 1
return DirectiveResult(applied=True, say=HARD_LINES[directive])
if directive == "address_wrong":
# Факт снимается с раскрытых: оператор обязан переспросить адрес,
# а в оценке полнота опроса снова считает его недобытым.
if state.slots is not None:
for fact in list(state.slots.revealed_facts()):
if "address" in fact.id:
state.slots.invalidate(fact.id)
return DirectiveResult(applied=True, say=HARD_LINES[directive])
# Свободный текст уходит в контекст персоны — но подставить его в реплику
# может только LLM. Офлайн-дерево предгенерировано, произвольную фразу
# взять неоткуда (docs/arch/CONTRACT.md).
if state.persona is not None:
state.persona.directive = None
if state.directives is not None:
state.directives.append(directive)
return DirectiveResult(applied=False, needs_network=True)
def mood_of(state) -> Mood:
return state.persona.mood if state.persona else Mood.PANIC