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 с кэшем.
This commit is contained in:
parent
4f5a8d1400
commit
7c97310938
22 changed files with 1073 additions and 26 deletions
103
backend/app/dialog/caller.py
Normal file
103
backend/app/dialog/caller.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Реплика звонящего.
|
||||
|
||||
`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
|
||||
Loading…
Reference in a new issue