lct-hack/backend/app/dialog/caller.py
Ivan Gerasimov 7c97310938 lct-07 (без LLM): слот-автомат, персона, звонящий на заготовках, make repl
Звонящий не выдаёт данные сам: автомат держит раскрытые факты явно и
отдаёт звонящему только их. Тест прогоняет длинную серию реплик и
проверяет, что ни один нераскрытый факт не прозвучал.

Главная находка — матчинг по порогу близости не работает. На
multilingual-e5-small настоящие вопросы дают 0.79–0.95, а «Оставайтесь
на линии» — до 0.89: диапазоны перекрываются, и любой порог либо выдаёт
адрес на «успокойтесь», либо не слышит «где вы находитесь?».

Решение — сравнение с ближайшим соседом: у пункта чек-листа несколько
формулировок (examples), рядом общий список не-вопросов
(checklists/common.yaml), и реплика засчитывается пункту, только если она
ближе к нему, чем к любому не-вопросу. На отложенных фразах: 19 из 20
вопросов, 0 из 8 ложных срабатываний.

Реплика режется только по границам предложений, со знаком: «?» для e5 —
сильный признак вопроса, без него «Куда ехать» уходит к не-вопросам.

Подсказка берёт неотработанный пункт из автомата. В живой сессии автомат
начнёт слышать оператора, когда голосовой контур передаст ему stt.final.

Ждёт ключа LLM: условие approach у скрытых фактов, звонящий своими
словами, dialog/llm.py с кэшем.
2026-09-17 13:57:43 +03:00

103 lines
4.1 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.

"""Реплика звонящего.
`TemplateCaller` работает без LLM: говорит только раскрытыми фактами и заготовками
по настроению. Это и офлайн-запасной путь, и режим `make repl`, пока нет ключа.
Звонящий на LLM встанет за тот же интерфейс `Caller`.
70% восприятия эмоции даёт текст: обрывки, повторы, незаконченные фразы.
Заготовки написаны так же — «алло! алло!», а не «я взволнован».
"""
from dataclasses import dataclass
from typing import Protocol
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine, TurnResult
from app.domain.events import Mood
@dataclass
class CallerLine:
text: str
mood: Mood
class Caller(Protocol):
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine: ...
#: Реплика, когда оператор спросил не то или непонятно. Выбор по номеру реплики,
#: а не случайный: сценарий занятия должен звучать одинаково у каждой группы.
FILLERS: dict[Mood, list[str]] = {
Mood.PANIC: [
"Алло?! Вы меня слышите?! Помогите быстрее!",
"Что?! Я не понимаю! Приезжайте!",
"Господи... быстрее, пожалуйста!",
],
Mood.WORRIED: [
"Простите, я не поняла вопрос...",
"Что именно вам сказать?",
],
Mood.CALM: [
"Уточните, пожалуйста, что вас интересует.",
"Не понял вопрос.",
],
Mood.AGGRESSIVE: [
"Да вы издеваетесь?! Хватит болтать, высылайте!",
"Сколько можно?! Люди горят!",
],
Mood.CONFUSED: [
"А? Кто это... что вы говорите?",
"Подождите... я забыл, что хотел...",
],
}
#: Как звонящий подаёт раскрытый факт.
REVEAL: 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}! Записывайте!",
Mood.WORRIED: "Я ведь уже говорила: {fact}.",
Mood.CALM: "Я уже сказал: {fact}.",
Mood.AGGRESSIVE: "Я ТРЕТИЙ РАЗ ГОВОРЮ — {fact}! Вы слушаете вообще?!",
Mood.CONFUSED: "Так я ж говорил... {fact}...",
}
class TemplateCaller:
def __init__(self) -> None:
self._turn = 0
def reply(self, turn: TurnResult, persona: PersonaState, slots: SlotMachine) -> CallerLine:
self._turn += 1
if turn.repeated:
for _ in turn.repeated:
persona.on_repeat()
mood = persona.remember()
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
parts: list[str] = []
for fact_id in turn.revealed:
parts.append(REVEAL[mood].format(fact=facts[fact_id]))
for fact_id in turn.repeated:
parts.append(REPEAT[mood].format(fact=facts[fact_id]))
if not parts:
options = FILLERS[mood]
parts.append(options[(self._turn - 1) % len(options)])
return CallerLine(text=" ".join(_sentence_case(part) for part in parts), mood=mood)
def _sentence_case(text: str) -> str:
"""Факт в сценарии записан как фрагмент («улица Ленина, 14»), а в начале
реплики должен звучать как начало фразы."""
return text[:1].upper() + text[1:] if text else text