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
50
backend/scripts/models.py
Normal file
50
backend/scripts/models.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""make models: веса моделей в backend/models/.
|
||||
|
||||
Сейчас качает эмбеддинги для слот-автомата. Распознавание (GigaAM) и синтез
|
||||
(Silero) добавит карточка lct-02 — после замера на демо-машине.
|
||||
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODELS = ROOT / "models"
|
||||
|
||||
E5 = "https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/"
|
||||
FILES = {
|
||||
"e5-small/config.json": E5 + "config.json",
|
||||
"e5-small/tokenizer.json": E5 + "tokenizer.json",
|
||||
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
|
||||
}
|
||||
|
||||
|
||||
def fetch(url: str, target: Path) -> None:
|
||||
partial = target.with_suffix(target.suffix + ".part")
|
||||
offset = partial.stat().st_size if partial.exists() else 0
|
||||
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||
with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
|
||||
total = offset + int(response.headers.get("Content-Length", 0))
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
done = out.tell()
|
||||
if total:
|
||||
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
|
||||
partial.rename(target)
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for relative, url in FILES.items():
|
||||
target = MODELS / relative
|
||||
if target.exists():
|
||||
print(f" {relative}: уже есть")
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fetch(url, target)
|
||||
print("эмбеддинги готовы; GigaAM и Silero — карточка tasks/lct-02-latency-baseline.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
94
backend/scripts/repl.py
Normal file
94
backend/scripts/repl.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""make repl: текстовый диалог со звонящим, без голоса и без LLM.
|
||||
|
||||
Проверяет главную механику до сборки голосового контура: звонящий не выдаёт
|
||||
данные сам, раскрывает факт на правильный вопрос и злится на повторы.
|
||||
|
||||
make repl сценарий по умолчанию
|
||||
make repl s=fire-apartment-l2 конкретный сценарий
|
||||
|
||||
Команды: /подсказка, /факты, /итог, /выход
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.dialog.caller import TemplateCaller # noqa: E402
|
||||
from app.dialog.embeddings import E5Embedder # noqa: E402
|
||||
from app.dialog.persona import PersonaState # noqa: E402
|
||||
from app.dialog.slots import SlotMachine # noqa: E402
|
||||
from app.scenarios.loader import ScenarioError, load_library # noqa: E402
|
||||
|
||||
LIBRARY = ROOT.parent / "scenarios"
|
||||
|
||||
|
||||
def summary(slots: SlotMachine) -> None:
|
||||
required = slots.scenario.ground_truth.required_facts
|
||||
got = [fact_id for fact_id in required if fact_id in slots.revealed]
|
||||
print(f"\n добыто обязательных фактов: {len(got)} из {len(required)}")
|
||||
for fact_id in slots.missing_required():
|
||||
item = next((i for i in slots.scenario.checklist if i.fact == fact_id), None)
|
||||
hint = f" — эталонный вопрос: «{item.question}»" if item else ""
|
||||
print(f" E1 не добыт {fact_id}{hint}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scenario_id = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] else None
|
||||
try:
|
||||
library = {scenario.id: scenario for scenario in load_library(LIBRARY)}
|
||||
except ScenarioError as exc:
|
||||
raise SystemExit(f"библиотека не прошла проверку: {exc}") from exc
|
||||
|
||||
scenario = library.get(scenario_id) if scenario_id else next(iter(library.values()))
|
||||
if scenario is None:
|
||||
raise SystemExit(f"нет сценария {scenario_id}; есть: {', '.join(library)}")
|
||||
|
||||
try:
|
||||
embedder = E5Embedder(ROOT / get_settings().models_dir / "e5-small")
|
||||
except FileNotFoundError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
slots = SlotMachine(scenario, embedder)
|
||||
persona = PersonaState(scenario.persona)
|
||||
caller = TemplateCaller()
|
||||
|
||||
print(f"── {scenario.title} ({scenario.level.value}) ──")
|
||||
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
|
||||
print(f"ЗВОНЯЩИЙ [{persona.mood.value}]: {scenario.first_line}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input("ОПЕРАТОР: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
if line == "/выход":
|
||||
break
|
||||
if line == "/итог":
|
||||
summary(slots)
|
||||
continue
|
||||
if line == "/факты":
|
||||
for fact in slots.revealed_facts():
|
||||
print(f" раскрыт {fact.id}: {fact.value}")
|
||||
continue
|
||||
if line == "/подсказка":
|
||||
unasked = slots.unasked()
|
||||
print(f" подсказка: {unasked[0].question}" if unasked else " все пункты чек-листа отработаны")
|
||||
continue
|
||||
|
||||
turn = slots.hear(line)
|
||||
reply = caller.reply(turn, persona, slots)
|
||||
if turn.matched:
|
||||
print(f" [понято: {', '.join(turn.matched)}]")
|
||||
print(f"ЗВОНЯЩИЙ [{reply.mood.value}]: {reply.text}")
|
||||
|
||||
summary(slots)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue