diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a17fb1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Schema, data, and unit checks (no training) + runs-on: ubuntu-22.04 + timeout-minutes: 20 + env: + PYTHONUTF8: "1" + PYTHONIOENCODING: "utf-8" + RASA_TELEMETRY_ENABLED: "false" + TF_CPP_MIN_LOG_LEVEL: "2" + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10.11" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install pinned dependencies + run: | + python -m pip install pip==24.3.1 + python -m pip install -r requirements-dev.txt + python -m pip check + python -m rasa --version + + - name: Validate Rasa data and domain + run: >- + python -m rasa data validate + --domain domain.yml + --data data + --fail-on-warnings + + - name: Run project-level static validation + run: python tests/static_validate.py + + - name: Run Python unit tests when present + shell: bash + run: | + if find tests -type f \( -name 'test_*.py' -o -name '*_test.py' \) -print -quit | grep -q .; then + python -m pytest -q + else + echo "No Python unit tests found; pytest skipped." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4aa8203 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +venv/ +.venv/ +.venv-rasa/ +.runtime/ + +# Rasa and generated evaluation data +.rasa/ +models/ +results/* +!results/README.md +!results/experiments.md +!results/final/ +results/final/* +!results/final/summary.md +!results/final/*_report.json +!results/final/*_errors.json +!results/final/*_confusion_matrix.png +!results/final/*_histogram.png +!results/final/runtime_report.json + +# Local application state +.config/ +.keras/ +.env +.env.* +!.env.example +*.log +.agents/ +.codex/ + +# IDE and operating system +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Historical unpacked duplicate and local audit artifacts +/rasa_diploma/ +/artifacts/ +/outputs/ +/output/ +/document_tools/ +/.audit_docx/ +*.backup + +# Personal diploma artifacts are not part of the public code repository +*.docx +*.pptx +*.pdf diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..09dcc78 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.11 diff --git a/README.md b/README.md new file mode 100644 index 0000000..24d1d41 --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +# Русскоязычный intent-based ассистент на Rasa + +Восстановленный и заново обученный дипломный проект Хуршида Мухаммадиева. +Это классическая диалоговая система: она не генерирует текст через LLM и не +обращается к внешнему API, а обучается на собственном размеченном корпусе, +распознаёт намерение пользователя и выбирает подготовленный ответ или действие. + +Проект специально оставлен CLI-first. Веб-интерфейс и публичный REST API здесь +не нужны: основная работа — данные, ML/NLU, оценка качества и воспроизводимый +запуск. + +## Результат + +Финальная модель обучена на 795 русскоязычных примерах и оценена на 235 +отложенных test-сообщениях. Test split не участвовал в обучении; после выбранного +финального прогона train и конфигурация модели не изменялись. + +| Метрика | Результат | +|---|---:| +| Raw Intent Accuracy | **89,36%** | +| Intent Macro-F1 | **90,23%** | +| Intent Weighted-F1 | **89,26%** | +| `person_name` token/tag F1 | **87,80%** | +| Строгая accuracy после fallback | **88,94%** | +| Coverage без fallback | **97,02%** | +| Selective accuracy | **91,67%** | + +Полный отчёт: [results/final/summary.md](results/final/summary.md). История двух +итераций и прирост от улучшения данных: [results/experiments.md](results/experiments.md). + +![Матрица ошибок intent](results/final/intent_confusion_matrix.png) + +Все цифры получены реальным запуском закреплённого окружения. Корпус составлен +вручную/синтетически, поэтому эти метрики не выдаются за качество на сообщениях +реальных пользователей. + +## Что обучается + +- 21 пользовательский intent: приветствие, помощь, вопросы о проекте, Rasa, + NLU, обучении, запуске, Docker, времени и другие сценарии; +- `DIETClassifier` классифицирует intent и извлекает сущность `person_name`; +- slot `user_name` хранит найденное имя в пределах текущей сессии; +- `FallbackClassifier` безопасно отклоняет запросы с низкой уверенностью; +- `RulePolicy` и `TEDPolicy` выбирают следующий ответ или custom action; +- четыре Python-action возвращают возможности, запоминают имя и называют дату + или время Ташкента. + +Примеры маршрутизации: + +```text +«Кто разработал помощника?» + → ask_creator → utter_creator + +«Меня зовут Азамат» + → inform_name + person_name="Азамат" → action_remember_name + +неподдерживаемая тема + → out_of_scope или nlu_fallback → ограниченный безопасный ответ +``` + +## Архитектура + +```mermaid +flowchart LR + U["Русская реплика"] --> F["Word и char_wb n-grams"] + F --> D["DIETClassifier"] + D --> N["intent + person_name"] + D -->|"низкая уверенность"| B["FallbackClassifier"] + N --> C["RulePolicy / TEDPolicy"] + B --> C + C --> R["Готовый ответ"] + C --> A["Python custom action"] +``` + +Стек закреплён для повторяемого запуска: Python 3.10.11, Rasa 3.6.21 и Rasa SDK +3.6.2. У DIET/TED по 100 эпох и `random_seed: 42`. Прямые зависимости закреплены, +но полного lock-файла транзитивных пакетов нет, поэтому побитовая идентичность +переобученной модели не обещается. + +## Данные и честная оценка + +| Split | Файл | Примеров | Назначение | +|---|---|---:|---| +| train | `data/nlu.yml` | 795 | обучение модели | +| dev | `tests/nlu_dev.yml` | 235 | диагностика и улучшение границ intent | +| test | `tests/nlu_test.yml` | 235 | финальная отложенная оценка | + +`tests/static_validate.py` проверяет таксономию, размеры split, YAML-связи, +реализации actions, закреплённые Docker-образы и гигиену разделения данных. Он +автоматически подтверждает: + +- отсутствие точных пересечений train/dev/test; +- отсутствие совпадающих train/test-шаблонов после маскировки entity; +- новые значения `person_name` в dev и test; +- по выбранной текстовой эвристике нет пар одного intent между train/test и + dev/test со score `>= 0,85`. + +Последняя проверка чувствительна к выбранной метрике и порогу: она не исключает +семантически близкие фразы и не доказывает статистическую независимость или +качество на реальном пользовательском трафике. + +`rasa test nlu` показывает исходное качество классификатора без влияния +fallback. `scripts/evaluate_runtime_nlu.py` отдельно пропускает те же сообщения +через полный pipeline и считает coverage, fallback rate и selective accuracy, +не сохраняя тексты сообщений в runtime-отчёт. + +В финальном прогоне fallback отклонил семь сообщений: шесть с ошибочным raw +intent и одно с верным. Поэтому он повысил точность среди принятых intent ценой +3% coverage и снижения строгой общей accuracy на 0,43 процентного пункта. + +## Быстрый запуск на Windows + +Нужен именно Python 3.10: текущая ветка Rasa 3.6 несовместима с Python 3.11+. + +```powershell +py -3.10 -m venv .venv-rasa +.\.venv-rasa\Scripts\python.exe -m pip install -r requirements-dev.txt +``` + +Проверка, обучение и финальная оценка: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate.ps1 +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\train.ps1 -Force +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\evaluate.ps1 +``` + +Оценка выполняет три прохода: штатный `rasa test nlu`, полный NLU pipeline с +`FallbackClassifier` и Core-тест диалоговой политики на той же модели. Модели +сохраняются в `models/`, а отчёты — в `results/final/`. + +Интерактивный разговор: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\chat.ps1 +``` + +Скрипт скрыто запускает локальный action server, ждёт его healthcheck, открывает +`rasa shell` и завершает фоновый процесс при выходе. + +## Запуск через Docker + +Docker не публикует HTTP-порты: контейнеры используются только для +воспроизводимого CLI-запуска. + +```powershell +docker compose run --rm --no-deps rasa data validate --fail-on-warnings +docker compose run --rm --no-deps rasa train +docker compose run --rm rasa +docker compose down +``` + +Rasa и action server общаются только во внутренней сети Compose через +`endpoints.docker.yml`. + +## Проверки + +```powershell +# статические инварианты и штатная валидация Rasa +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\validate.ps1 + +# unit-тесты четырёх custom actions +.\.venv-rasa\Scripts\python.exe -m pytest -q + +# Core-сценарии для уже обученной модели +.\.venv-rasa\Scripts\python.exe -m rasa test core ` + --model .\models\.tar.gz ` + --stories .\tests\test_stories.yml ` + --out .\results\core +``` + +Проверенный результат Core: **10/10 историй и 47/47 действий**, из которых 23 — +технические `action_listen`. Это Core-only проверка с заранее заданными gold +intents, а не end-to-end оценка распознавания пользовательских фраз. GitHub +Actions выполняет schema/data/unit checks, но намеренно не обучает DIET/TED на +каждом push; зелёный CI сам по себе не подтверждает ML-метрики. + +## Структура репозитория + +```text +actions/ custom actions на Python +data/ train NLU, rules и stories +scripts/ validate/train/evaluate/chat +tests/ dev/test NLU, Core и unit-тесты +results/final/ реальные отчёты выбранной модели +config.yml NLU pipeline и dialogue policies +domain.yml intents, entity, slot, ответы и actions +docker-compose.yml CLI-only контейнерный запуск +requirements*.txt закреплённые зависимости +``` + +Модели, виртуальные окружения, личные DOCX/PPTX/PDF, логи и старые копии проекта +исключены из Git. Для публичного репозитория подготовлены только код, данные и +проверяемые результаты экспериментов. + +## Ограничения + +- test-данные не являются выборкой из реального пользовательского трафика; +- модель выбирает подготовленные ответы и не решает произвольные задачи; +- Rasa Open Source 3.6 — legacy-ветка: её период поддержки уже завершён согласно + [официальной политике Rasa](https://rasa.com/rasa-product-release-and-maintenance-policy). + Она оставлена для исторической совместимости учебного проекта, но не + рекомендуется как основа нового production-сервиса; +- Docker-конфигурация подготовлена, но локальный прогон в этой среде не + выполнялся из-за отсутствия Docker. + +Следующий содержательный шаг для ML-части — собрать анонимный набор реальных +реплик, не изменять по нему train и повторить независимую оценку. + +## Лицензия + +Код и данные проекта распространяются по лицензии [MIT](LICENSE). diff --git a/actions/__init__.py b/actions/__init__.py new file mode 100644 index 0000000..1298755 --- /dev/null +++ b/actions/__init__.py @@ -0,0 +1 @@ +"""Custom actions package for the Rasa SDK action server.""" diff --git a/actions/actions.py b/actions/actions.py new file mode 100644 index 0000000..c021dc0 --- /dev/null +++ b/actions/actions.py @@ -0,0 +1,104 @@ +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Text + +from rasa_sdk import Action, Tracker +from rasa_sdk.executor import CollectingDispatcher + + +class ActionRememberName(Action): + def name(self) -> Text: + return "action_remember_name" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + user_name = tracker.get_slot("user_name") + + if user_name: + dispatcher.utter_message( + text=f"Приятно познакомиться, {user_name}! Я запомню ваше имя в рамках текущего диалога." + ) + else: + dispatcher.utter_message( + text="Я не смог выделить имя. Напишите, например: «Меня зовут Анна»." + ) + + return [] + + +class ActionShowCapabilities(Action): + def name(self) -> Text: + return "action_show_capabilities" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + user_name = tracker.get_slot("user_name") + subject = f"{user_name}, я" if user_name else "Я" + dispatcher.utter_message( + text=( + f"{subject} могу рассказать о себе, авторе и назначении проекта, " + "объяснить Rasa, NLU, Docker, обучение, запуск и структуру файлов, " + "запомнить имя, назвать дату и время, поддержать короткий разговор " + "и пошутить." + ) + ) + return [] + + +class ActionShowTime(Action): + def name(self) -> Text: + return "action_show_time" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + tashkent_time = datetime.now(timezone(timedelta(hours=5))) + dispatcher.utter_message( + text=f"Сейчас {tashkent_time:%H:%M} по времени Ташкента (UTC+5)." + ) + return [] + + +class ActionShowDate(Action): + def name(self) -> Text: + return "action_show_date" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + tashkent_date = datetime.now(timezone(timedelta(hours=5))) + month_names = ( + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", + ) + dispatcher.utter_message( + text=( + f"Сегодня {tashkent_date.day} " + f"{month_names[tashkent_date.month - 1]} {tashkent_date.year} года " + "по времени Ташкента." + ) + ) + return [] diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..b8a7b78 --- /dev/null +++ b/config.yml @@ -0,0 +1,34 @@ +recipe: default.v1 +language: ru + +pipeline: + - name: WhitespaceTokenizer + - name: LexicalSyntacticFeaturizer + - name: CountVectorsFeaturizer + analyzer: word + - name: CountVectorsFeaturizer + analyzer: char_wb + min_ngram: 3 + max_ngram: 5 + - name: DIETClassifier + epochs: 100 + constrain_similarities: true + random_seed: 42 + - name: FallbackClassifier + threshold: 0.55 + ambiguity_threshold: 0.10 + +policies: + - name: MemoizationPolicy + max_history: 5 + - name: RulePolicy + core_fallback_threshold: 0.30 + core_fallback_action_name: utter_default + enable_fallback_prediction: true + - name: TEDPolicy + max_history: 5 + epochs: 100 + constrain_similarities: true + random_seed: 42 + +assistant_id: diploma-russian-study-assistant diff --git a/data/nlu.yml b/data/nlu.yml new file mode 100644 index 0000000..521eece --- /dev/null +++ b/data/nlu.yml @@ -0,0 +1,860 @@ +version: "3.1" + +nlu: +- intent: greet + examples: | + - привет + - здравствуй + - здравствуйте + - доброе утро + - добрый день + - добрый вечер + - приветствую + - салют + - рад встрече + - рад тебя видеть + - начинаем разговор + - давай познакомимся + - всем привет + - привет, помощник + - здравствуй, бот + - доброго утра + - доброго дня + - доброго вечера + - я снова здесь + - можно начать беседу + - хочу поздороваться + - передаю привет + - привет тебе + - ну здравствуй + - хей, начнём + - предлагаю познакомиться и начать разговор + - помощник, выходи на связь + - открываем новую беседу + - рад нашей первой встрече + - в диалог вошёл новый пользователь + - можем начинать общение + - приветствую виртуального собеседника + - я пришёл поговорить с помощником + - давай откроем разговор + - начинаю общение с ботом + +- intent: goodbye + examples: | + - пока + - до свидания + - до встречи + - всего доброго + - всего хорошего + - увидимся позже + - мне пора + - я ухожу + - завершим беседу + - закончим разговор + - на этом всё + - буду прощаться + - хорошего дня + - спокойной ночи + - ещё увидимся + - до скорого + - прощай + - отключаюсь + - мне нужно идти + - спасибо, пока + - до следующего раза + - заверши диалог + - больше вопросов нет + - увидимся + - заканчиваем + - пора закрывать наш разговор + - вернусь с вопросами в другой раз + - я закончил работу с помощником + - остановим беседу на сегодня + - благодарю, теперь буду прощаться + - закрой текущий диалог + - продолжим общение как-нибудь потом + - на сегодня разговор окончен + - мне пора отключиться от чата + - желаю успехов и ухожу + +- intent: thanks + examples: | + - спасибо + - большое спасибо + - благодарю + - спасибо за помощь + - очень признателен + - премного благодарен + - ты мне помог + - благодарю за объяснение + - спасибо за подробности + - это было полезно, спасибо + - ценю твою помощь + - спасибо за понятный ответ + - огромное спасибо + - благодарю тебя + - признателен за информацию + - выручил, спасибо + - от души благодарю + - спасибо, теперь понятно + - благодарю за подсказку + - весьма признателен + - спасибо за разъяснение + - ты очень помог, благодарю + - выражаю благодарность + - спасибо за уделённое время + - благодарю за точный ответ + - твоя рекомендация мне пригодилась + - подсказка оказалась полезной + - теперь всё стало ясно + - хорошо объяснил, благодарю + - ответ действительно помог разобраться + - ценная информация, признателен + - благодаря тебе я понял тему + - отличный совет, он сработал + - это именно то, что было нужно + - помощь была кстати + +- intent: help + examples: | + - что ты умеешь + - чем ты можешь помочь + - покажи свои возможности + - какие у тебя функции + - о чём тебя можно спросить + - перечисли доступные команды + - расскажи, что тебе известно + - нужна подсказка по возможностям + - с какими вопросами ты работаешь + - объясни, чем занимаешься + - дай список доступных тем + - в чём ты можешь быть полезен + - подскажи, как с тобой общаться + - какие запросы ты понимаешь + - покажи справку + - мне нужна помощь + - помоги разобраться с твоими функциями + - что можно здесь узнать + - какие темы поддерживаются + - расскажи о доступных действиях + - что мне у тебя спросить + - как пользоваться этим помощником + - открой перечень возможностей + - нужна инструкция по общению + - на какие вопросы ты отвечаешь + - сделай обзор доступных навыков + - покажи пользовательское руководство + - объясни правила разговора с тобой + - перечисли темы, в которых ты ориентируешься + - с чего начать работу с помощником + - предложи несколько вариантов вопроса + - дай короткую памятку пользователю + - какие действия здесь доступны + - познакомь меня со своими функциями + - подскажи поддерживаемые направления + +- intent: ask_name + examples: | + - кто ты + - как тебя зовут + - представься + - у тебя есть имя + - как к тебе обращаться + - назови своё имя + - ты кто такой + - какое у тебя имя + - скажи, кто ты + - кем ты являешься + - как называется этот помощник + - можешь представиться + - расскажи о себе кратко + - что ты за ассистент + - ты бот или человек + - какое имя у бота + - под каким именем ты работаешь + - хочу узнать твоё имя + - кто сейчас мне отвечает + - как мне тебя называть + - обозначь себя + - что ты собой представляешь + - назови себя + - с кем я разговариваю + - кто мой собеседник + - сообщи имя виртуального ассистента + - под каким названием тебя знают + - как подписан этот бот + - представь личность собеседника + - какое обращение выбрать для тебя + - назови себя как участника диалога + - хочу понять, кто отвечает мне сейчас + - какое имя носит помощник + - идентифицируй себя как ассистента + - с каким ботом я веду разговор + +- intent: ask_creator + examples: | + - кто тебя создал + - кто твой автор + - кем ты разработан + - кто написал этого бота + - расскажи о создателе + - кто разработчик ассистента + - назови автора программы + - чей это проект + - кто занимался разработкой + - кто сделал эту систему + - кому принадлежит авторство + - расскажи о своём разработчике + - какой студент тебя реализовал + - кто подготовил дипломного бота + - назови имя создателя + - кем создан этот помощник + - кто является автором работы + - чья разработка передо мной + - кто программировал ассистента + - сообщи данные разработчика + - кто работал над этим ботом + - автор проекта кто + - кто реализовал данную систему + - чьим дипломным проектом ты являешься + - расскажи, кем была выполнена работа + - кто выступил исполнителем дипломной разработки + - назови студента-разработчика системы + - кто написал исходный код помощника + - чья работа представлена в репозитории + - сообщи имя автора реализации + - какой разработчик собрал этого ассистента + - кто отвечает за создание программы + - кем выполнена программная часть диплома + - укажи человека, разработавшего бота + - кому принадлежит реализация проекта + +- intent: ask_project + examples: | + - расскажи о дипломном проекте + - какая тема проекта + - какова цель этой работы + - что демонстрирует проект + - для чего создан этот ассистент + - о чём твой проект + - в чём состоит дипломная разработка + - какую задачу решает система + - опиши назначение проекта + - какова идея диплома + - что было разработано в рамках работы + - чему посвящено исследование + - какой результат получен в проекте + - расскажи содержание дипломной работы + - зачем создавался виртуальный помощник + - в чём практический смысл разработки + - какая проблема рассматривается в дипломе + - опиши проект одним абзацем + - что является объектом разработки + - какую концепцию показывает ассистент + - сформулируй цель диплома + - что представляет собой эта работа + - какие задачи ставились в проекте + - расскажи о тематике исследования + - для какой демонстрации сделан бот + - каков основной замысел разработки + - дай общее описание созданной системы + - какую пользу демонстрирует этот проект + - в чём заключается главная цель решения + - резюмируй содержание разработки + - какой результат подтверждает эта разработка + - объясни идею виртуального помощника + - что исследуется с помощью этого бота + - назови назначение готовой системы + - какой практический результат у диплома + +- intent: inform_name + examples: | + - меня зовут [Александр](person_name) + - моё имя [Екатерина](person_name) + - я [Михаил](person_name) + - можешь звать меня [Наталья](person_name) + - обращайся ко мне как [Тимур](person_name) + - хочу представиться: [Диана](person_name) + - моё полное имя [Иван Петров](person_name) + - я представлюсь, [Анна Смирнова](person_name) + - называй меня [Али-Шер](person_name) + - для тебя я [Мария-Луиза](person_name) + - запомни имя [Абдулла Рахимов](person_name) + - при знакомстве меня называют [Софья](person_name) + - в этом диалоге я [Рустам](person_name) + - моё имя и отчество [Елена Викторовна](person_name) + - записывай: [Павел Сергеевич](person_name) + - представлюсь как [Николай](person_name) + - друзья зовут меня [Зарина](person_name) + - можно обращаться [Олег Ким](person_name) + - в беседе используй имя [Светлана Орлова](person_name) + - представь, что меня зовут [Бекзод Юлдашев](person_name) + - буду знакома, [Виктория](person_name) + - приятно познакомиться, я [Артём](person_name) + - при обращении говори [Камилла](person_name) + - сохрани, пожалуйста, имя [Фаррух](person_name) + - здесь я записан как [Лев](person_name) + - представь меня именем [Надежда](person_name) + - зовут меня [Илья Воронцов](person_name) + - в профиле указано [Малика Абдуллаева](person_name) + - мое имя пишется [Жан-Клод](person_name) + - ко мне можно обращаться по имени [Алина](person_name) + - скажу своё имя — [Георгий](person_name) + - разрешаю называть меня [Лилия](person_name) + - пожалуйста, запомни: [Владислав](person_name) + - собеседника зовут [Инна](person_name) + - я хотел бы представиться как [Денис](person_name) + - используйте для обращения имя [Эльвира](person_name) + - во время разговора называй меня [Константин](person_name) + - моё настоящее имя — [Айгуль](person_name) + - давай знакомиться, я [Роман](person_name) + - можешь сохранить имя [Татьяна](person_name) + - укажи в профиле имя [Ярослав](person_name) + - для текущего разговора я [Саида](person_name) + - обращайся к собеседнику [Кирилл Новиков](person_name) + - мой профиль принадлежит [Азиза Ахмедова](person_name) + - можешь говорить мне [Арсен](person_name) + - запиши моё имя: [Мирослава](person_name) + - в этом чате называй меня [Даврон](person_name) + - полное имя пользователя [Полина Громова](person_name) + - договоримся, что я [Шахзода](person_name) + - обращение для меня — [Евгений Ильин](person_name) + - создай запись пользователя [Злата](person_name) + - в текущем чате используй [Умар Саидов](person_name) + - моим именем будет [Вероника](person_name) + - добавь в карточку [Борис Кузнецов](person_name) + - прошу обращаться ко мне [Нигина](person_name) + - имя участника диалога — [Самир](person_name) + - сохрани правильное написание: [Яна Белова](person_name) + - можешь считать, что я [Антон](person_name) + - полностью меня зовут [Мадина Юсупова](person_name) + - выбери для меня обращение [Глеб](person_name) + +- intent: ask_restart + examples: | + - начни диалог заново + - давай сначала + - перезапусти разговор + - сбрось диалог + - хочу начать сначала + - очисти нашу беседу + - забудь текущий контекст + - верни разговор к началу + - перезагрузи сессию + - сотри историю этого диалога + - начнём общение с чистого листа + - сбрось сохранённые данные беседы + - хочу новую сессию + - аннулируй текущий разговор + - выполни перезапуск чата + - удали контекст и начни снова + - обнули состояние помощника + - перезапусти текущую беседу + - верни исходное состояние + - начни новый сеанс общения + - забудь всё из этого разговора + - очисти память текущей сессии + - можно открыть новый диалог + - прекрати эту сессию и создай новую + - хочу поговорить заново без истории + - удали предыдущие сообщения из контекста + - создай пустую беседу с самого начала + - верни диалог к пустому начальному состоянию + - очисти всё, что бот помнит о разговоре + - открой свежую сессию без прошлого + - начни новый чат с нулевым контекстом + - сбрось память и вернись к старту + - отмени историю текущего общения + - перезапусти помощника для новой беседы + - хочу чистый диалог без прежних данных + +- intent: ask_rasa + examples: | + - что такое Rasa + - расскажи о платформе Rasa + - для чего предназначена Rasa + - какую роль Rasa играет в проекте + - почему выбрана именно Rasa + - Rasa является фреймворком + - опиши основные компоненты Rasa + - что умеет платформа Rasa + - чем Rasa полезна для чат-ботов + - дай определение Rasa + - какая архитектура у Rasa + - из каких частей состоит Rasa + - чем Rasa отличается от обычного скрипта + - является ли Rasa открытым решением + - объясни назначение Rasa в ассистенте + - на чём основана платформа Rasa + - какие задачи решает Rasa как система + - что входит в экосистему Rasa + - расскажи об устройстве Rasa в общих чертах + - зачем чат-боту нужен фреймворк Rasa + - почему Rasa подходит для диалоговой системы + - какую часть помощника обеспечивает Rasa + - что представляет собой Rasa Open Source + - где в архитектуре находится Rasa + - объясни идею использования Rasa + - перечисли основные возможности фреймворка Rasa + - каково назначение платформы для диалогов + - какую функцию Rasa выполняет в ассистенте + - дай обзор Rasa без описания тренировки + - почему этот фреймворк подходит для NLU-бота + - какие подсистемы объединяет платформа + - опиши Rasa как инструмент разработчика + - что предоставляет открытая версия Rasa + - для чего в решении используется этот фреймворк + - расскажи о роли Rasa в общем виде + +- intent: ask_nlu + examples: | + - что означает NLU + - как ассистент понимает текст + - что такое распознавание намерений + - как бот определяет intent + - какую информацию содержит entity + - объясни обработку естественного языка + - что называется намерением пользователя + - каким образом определяется смысл сообщения + - зачем извлекать сущности из фразы + - в чём задача NLU-компонента + - как текст превращается в структурированные данные + - чем intent отличается от entity + - что происходит с сообщением на этапе NLU + - как классифицируются пользовательские запросы + - объясни термин классификация намерений + - для чего нужна разметка сущностей + - как система выделяет имя в сообщении + - какие данные выдаёт NLU после анализа + - что понимается под естественным языком + - как определяется категория вопроса + - зачем диалоговой системе анализ текста + - расскажи о задаче intent classification + - каким результатом завершается NLU-анализ + - что модель ищет в пользовательской фразе + - как связаны сообщение, intent и entity + - зачем аннотировать имя внутри примера + - как модель выбирает категорию реплики + - объясни смысл сущности person_name + - каким образом фраза относится к одному intent + - что извлекается из текста при понимании запроса + - почему intent описывает цель пользователя + - как DIET распознаёт смысл входного сообщения + - зачем разделять намерения и сущности + - какой структурированный результат создаёт NLU + - как имя обнаруживается в новой реплике + +- intent: ask_training + examples: | + - как обучается модель + - что требуется для обучения классификатора + - каким образом создают обученную модель + - расскажи об этапе тренировки + - на каких данных учится ассистент + - что происходит во время машинного обучения + - как переобучить NLU после изменения примеров + - сколько эпох используется при тренировке + - какие файлы участвуют в обучении модели + - как запустить процесс тренировки + - для чего модели нужны размеченные фразы + - что получает алгоритм на вход при обучении + - как формируется архив обученной модели + - когда нужно выполнять повторное обучение + - объясни последовательность обучения + - какие компоненты настраиваются во время тренировки + - как данные влияют на качество классификатора + - где задаётся количество эпох обучения + - можно ли обучить модель после добавления intent + - чем тренировочный набор отличается от тестового + - как проверить данные перед обучением + - что именно запоминает модель при тренировке + - почему модель необходимо переобучать + - опиши подготовку корпуса к обучению + - какой результат создаёт процедура тренировки + - какие этапы проходит корпус перед созданием модели + - как размеченные примеры превращаются в классификатор + - зачем при обучении задают число эпох + - как собрать новый архив модели из данных + - что происходит до появления обученного артефакта + - объясни цикл train и validation + - как подготовить выборки для тренировки DIET + - от чего зависит сходимость обучения + - какой командой пересобрать модель после правок данных + - опиши путь от NLU-корпуса до файла модели + +- intent: ask_run + examples: | + - как запустить готового бота + - что сделать для старта ассистента + - какой командой открыть консольный чат + - как включить уже обученную модель + - подскажи порядок запуска программы + - как начать работу с помощником локально + - что нужно выполнить после получения модели + - как активировать диалоговый режим + - какой процесс обслуживает разговор с ботом + - как запустить сервер пользовательских действий + - что надо включить перед общением + - как проверить ассистента в командной строке + - опиши последовательность старта сервисов + - как загрузить сохранённую модель для диалога + - каким способом открыть интерактивный режим + - где вводить сообщения после запуска + - что запускается для проверки готового проекта + - как поднять action server отдельно + - как убедиться, что бот отвечает локально + - какая команда включает режим shell + - как использовать модель после тренировки + - расскажи о локальном старте приложения + - как инициировать беседу с ассистентом + - что делать, чтобы бот начал принимать фразы + - как выполнить пробный запуск системы + - как открыть shell после готового обучения + - в каком порядке включить actions и консольный чат + - где протестировать ответы сохранённой модели + - как использовать уже созданный архив модели + - что запустить для локального разговора с ботом + - как проверить готового помощника вручную + - объясни старт процессов после тренировки + - какой командой загрузить модель в интерактивном режиме + - как подключить action server при запуске shell + - что нужно включить, когда модель уже обучена + +- intent: ask_project_structure + examples: | + - как устроены файлы проекта + - покажи структуру каталогов + - где хранятся обучающие данные + - что содержится в domain yml + - в каком файле находятся пользовательские действия + - за что отвечает config yml + - где описаны диалоговые правила + - какой каталог содержит stories + - что лежит в папке actions + - где перечислены ответы бота + - в каком месте объявлены intents + - объясни назначение основных файлов + - где настроен NLU pipeline + - какой файл связывает action server + - где расположены тестовые сценарии + - как организована директория data + - что записано в endpoints yml + - где задаются slots и entities + - из чего состоит репозиторий + - покажи схему папок приложения + - какой файл содержит конфигурацию политик + - где найти исходный код custom actions + - как распределены настройки по YAML-файлам + - в какой папке лежат NLU-примеры + - опиши файловую организацию ассистента + - в каком YAML находятся шаблоны responses + - где расположен Python-код действий + - какой файл содержит примеры реплик пользователя + - покажи путь к настройкам slots + - где искать декларации пользовательских actions + - в каком каталоге сохранены правила диалога + - где находится конфигурация NLU-конвейера + - укажи расположение тестовых NLU-данных + - где в репозитории лежит domain + - как файлы распределены между data actions и tests + +- intent: ask_docker + examples: | + - что такое Docker + - зачем этому проекту контейнеры + - какую пользу даёт Docker Compose + - объясни принцип контейнеризации + - почему Rasa помещена в контейнер + - что изолирует Docker в приложении + - как связаны контейнер Rasa и action server + - для чего нужен compose-файл + - какие сервисы описаны в Docker Compose + - чем контейнер отличается от виртуальной машины + - почему контейнеры упрощают воспроизводимость + - какую среду предоставляет Docker + - как Docker фиксирует зависимости проекта + - что находится внутри образа Rasa + - зачем разделять бота и actions по сервисам + - как контейнеры общаются между собой + - какие порты используются сервисами + - что означает образ в Docker + - зачем в репозитории docker-compose yml + - как контейнеризация помогает переносу проекта + - что даёт закрепление версии образа + - объясни сетевое взаимодействие сервисов + - почему action server работает отдельно + - какую проблему окружения решает Docker + - расскажи об использовании Compose в архитектуре + - зачем сервисам нужна общая сеть Compose + - как контейнеры находят друг друга по имени + - что означает публикация порта контейнера + - почему actions вынесены в отдельный процесс + - как Rasa обращается к action server внутри сети + - зачем закреплять тег Docker-образа + - какие процессы изолированы разными сервисами + - объясни адрес action_server в Compose + - чем внутренний порт отличается от опубликованного + - как контейнеры делают запуск воспроизводимым + +- intent: feedback_bad + examples: | + - этот ответ мне не помог + - информация оказалась неверной + - ты неправильно понял мой вопрос + - полученный ответ бесполезен + - объяснение слишком непонятное + - результат меня не устраивает + - ответ не относится к моему запросу + - ты сообщил не то, что я спрашивал + - мне нужна более точная информация + - это объяснение не решает проблему + - ответ получился ошибочным + - я недоволен качеством ответа + - бот понял намерение неправильно + - предложенная информация не подходит + - ты выбрал неверную тему + - ответ оказался не по существу + - смысл моего сообщения распознан неверно + - это совсем не тот результат + - пояснение требует исправления + - ответь иначе, предыдущий вариант плохой + - мой запрос был о другом + - данная реакция ассистента ошибочна + - ты перепутал, что я хотел узнать + - такой ответ нельзя считать полезным + - необходимо дать более ясное объяснение + - система выбрала неправильное намерение + - этот ответ не соответствует смыслу вопроса + - результат распознавания требует исправления + - пояснение только сильнее запутало меня + - полученная реакция была неуместной + - ты неверно определил цель моего сообщения + - предложенный вариант оказался бесполезным + - ответ нужно сделать точнее и понятнее + - это не решило мой исходный вопрос + - бот ответил совсем на другую тему + +- intent: ask_time + examples: | + - который сейчас час + - сколько времени + - скажи текущее время + - подскажи время в Ташкенте + - какое сейчас время суток + - назови точное время + - сколько на часах + - сообщи местное время + - который час по Ташкенту + - хочу узнать время + - покажи часы + - можно узнать, сколько сейчас времени + - назови часы и минуты + - каково текущее ташкентское время + - сообщи время по UTC плюс пять + - что показывают часы сейчас + - укажи актуальное время + - мне нужно местное время + - подскажи, который теперь час + - какое время показывает система + - скажи время без даты + - сколько времени в часовом поясе Ташкента + - хочу сверить часы + - выведи текущие часы и минуты + - сообщи, который час в данный момент + - покажи только часы и минуты без календаря + - назови время на местных часах + - сообщи текущую временную отметку + - сколько часов и минут сейчас в городе + - мне нужны показания часов, а не дата + - укажи момент времени по Ташкенту + - покажи текущее значение часов + - выведи час и минуту текущего момента + - хочу проверить местные часы + - скажи точное время без числа и месяца + +- intent: ask_date + examples: | + - какая сегодня дата + - какое сегодня число + - назови текущую дату + - какой сейчас день месяца + - сообщи дату по Ташкенту + - какой сегодня день календаря + - укажи число и месяц + - хочу узнать сегодняшнюю дату + - покажи календарную дату + - назови день, месяц и год + - какое число на календаре + - сообщи актуальную дату + - что сегодня за дата + - какой год, месяц и число сейчас + - напомни сегодняшнее число + - дата на сегодня какая + - выведи текущий день календаря + - скажи дату без времени + - какой день месяца наступил + - укажи дату в Ташкенте + - что показывает календарь сегодня + - мне нужна дата этого дня + - назови полную сегодняшнюю дату + - какое сейчас календарное число + - сообщи день и месяц + - покажи только календарное число без часов + - назови сегодняшние день месяц и год + - какая дата стоит в местном календаре + - мне нужны сведения календаря, а не время + - укажи число текущего дня + - какой день отмечен сегодня в календаре + - сообщи год месяц и день на сегодня + - назови календарную отметку этого дня + - какое число наступило в Ташкенте + - выведи дату без часов и минут + +- intent: smalltalk_howareyou + examples: | + - как дела + - как ты себя чувствуешь + - какое у тебя настроение + - всё ли у тебя хорошо + - как поживаешь + - ты в порядке + - как проходит твой день + - что у тебя нового + - как твоё состояние + - всё работает нормально + - как жизнь, помощник + - не устал отвечать + - как сегодня настроение + - у тебя всё хорошо + - как идут дела у бота + - ты готов к разговору + - как себя ощущает ассистент + - надеюсь, у тебя всё отлично + - что скажешь о своём настроении + - как твои дела сегодня + - нормально себя чувствуешь + - система в хорошем состоянии + - как проходит работа + - ты сегодня бодр + - всё в порядке у тебя + - давно не общались, как поживает бот + - как обстановка в твоём цифровом мире + - каково состояние твоих систем сегодня + - расскажи, как проходит твоя работа сегодня + - в каком ты сейчас настроении + - как себя ведёт помощник в этот день + - всё спокойно у виртуального собеседника + - каково твоё самочувствие, бот + - есть ли у тебя новости о себе + - готов ли ты сегодня общаться + +- intent: smalltalk_joke + examples: | + - расскажи шутку + - пошути + - знаешь анекдот + - скажи что-нибудь смешное + - развесели меня + - хочу услышать шутку + - подними мне настроение юмором + - можешь рассказать анекдот + - выдай забавную фразу + - давай немного юмора + - рассмеши меня + - расскажи смешную историю + - есть хорошая шутка + - скажи забавный ответ + - хочу посмеяться + - поделись коротким анекдотом + - придумай что-нибудь весёлое + - время для шутки + - добавь юмора в разговор + - расскажи что-то забавное + - можешь меня развеселить + - пошути про программистов + - нужен смешной комментарий + - произнеси весёлую фразу + - есть повод улыбнуться + - удиви остроумным ответом + - устрой короткую минуту юмора + - скажи реплику, от которой можно улыбнуться + - добавь в беседу что-нибудь комичное + - хочу немного повеселиться + - придумай забавный комментарий + - развесели короткой историей + - поделись чем-нибудь остроумным + - сделай разговор веселее + - выдай порцию хорошего юмора + +- intent: out_of_scope + examples: | + - приготовь рецепт плова + - какой сегодня курс доллара + - назови столицу Японии + - переведи это предложение на английский + - реши квадратное уравнение + - посоветуй фильм на вечер + - закажи мне такси + - найди расписание поездов + - поставь медицинский диагноз + - кто победил в футбольном матче + - составь гороскоп для овна + - напиши стихотворение о море + - какую книгу почитать + - дай совет по отношениям + - сколько стоит новый ноутбук + - кто сейчас президент Франции + - нарисуй изображение кота + - отправь письмо преподавателю + - установи будильник на семь утра + - проложи маршрут до аэропорта + - стоит ли покупать акции + - объясни прогноз цены биткоина + - проверь мой юридический договор + - включи любимую музыку + - сколько калорий в пирожном + - подбери гостиницу в Самарканде + - расскажи новости за сегодня + - придумай пароль для аккаунта + - вызови мастера по ремонту + - найди ближайшую аптеку + - кто написал роман Война и мир + - вычисли интеграл функции + - помоги выбрать стиральную машину + - забронируй столик в ресторане + - какая команда выиграла чемпионат + - проверь орфографию моего сочинения + - расскажи о лечении простуды + - напомни купить продукты вечером + - узнай статус посылки + - составь план тренировок в спортзале + - сколько лететь до Москвы + - найди работу программистом + - определи песню по мелодии + - подскажи номер телефона банка + - рассчитай ежемесячный платёж по кредиту + - выбери подарок на день рождения + - покажи расписание кинотеатра + - закажи доставку еды + - расскажи правила игры в шахматы + - включи свет в комнате + - помоги починить домашний маршрутизатор + - расскажи биографию Николы Теслы + - составь экскурсию по музеям Испании + - напиши программу на Python для магазина + - создай слайды о Древнем Риме + - как запустить стиральную машину Bosch + - какое имя дать домашнему коту + - сколько времени варить картофель + - когда состоятся следующие выборы + - сообщи дату выхода нового смартфона + - объясни лечение аллергии у ребёнка + - найди прогноз погоды для Бухары + - помоги настроить Wi-Fi на телефоне + - расскажи о языке программирования Java + - кто является автором картины Мона Лиза + - создай новый аккаунт в Telegram + - предложи структуру курсовой по экономике + - обучи меня играть на фортепиано + - назови текущее население Германии + - придумай шутку про политического деятеля diff --git a/data/rules.yml b/data/rules.yml new file mode 100644 index 0000000..73062d8 --- /dev/null +++ b/data/rules.yml @@ -0,0 +1,113 @@ +version: "3.1" + +rules: +- rule: Приветствие + steps: + - intent: greet + - action: utter_greet + +- rule: Прощание + steps: + - intent: goodbye + - action: utter_goodbye + +- rule: Ответ на благодарность + steps: + - intent: thanks + - action: utter_you_are_welcome + +- rule: Показ возможностей + steps: + - intent: help + - action: action_show_capabilities + +- rule: Имя ассистента + steps: + - intent: ask_name + - action: utter_name + +- rule: Автор ассистента + steps: + - intent: ask_creator + - action: utter_creator + +- rule: Описание проекта + steps: + - intent: ask_project + - action: utter_project + +- rule: Сохранение имени пользователя + steps: + - intent: inform_name + - action: action_remember_name + +- rule: Перезапуск разговора + steps: + - intent: ask_restart + - action: utter_restart + - action: action_restart + +- rule: Объяснение Rasa + steps: + - intent: ask_rasa + - action: utter_rasa + +- rule: Объяснение NLU + steps: + - intent: ask_nlu + - action: utter_nlu + +- rule: Объяснение обучения + steps: + - intent: ask_training + - action: utter_training + +- rule: Объяснение запуска + steps: + - intent: ask_run + - action: utter_run + +- rule: Объяснение структуры проекта + steps: + - intent: ask_project_structure + - action: utter_project_structure + +- rule: Объяснение Docker + steps: + - intent: ask_docker + - action: utter_docker + +- rule: Отрицательная обратная связь + steps: + - intent: feedback_bad + - action: utter_feedback_bad + +- rule: Текущее время + steps: + - intent: ask_time + - action: action_show_time + +- rule: Текущая дата + steps: + - intent: ask_date + - action: action_show_date + +- rule: Ответ на вопрос о состоянии + steps: + - intent: smalltalk_howareyou + - action: utter_howareyou + +- rule: Небольшая шутка + steps: + - intent: smalltalk_joke + - action: utter_joke + +- rule: Известная неподдерживаемая тема + steps: + - intent: out_of_scope + - action: utter_out_of_scope + +- rule: Низкая уверенность NLU + steps: + - intent: nlu_fallback + - action: utter_default diff --git a/data/stories.yml b/data/stories.yml new file mode 100644 index 0000000..71a06bd --- /dev/null +++ b/data/stories.yml @@ -0,0 +1,93 @@ +version: "3.1" + +stories: +- story: Знакомство и обзор проекта + steps: + - intent: greet + - action: utter_greet + - intent: inform_name + entities: + - person_name: Хуршид + - action: action_remember_name + - intent: help + - action: action_show_capabilities + - intent: ask_project + - action: utter_project + +- story: Ассистент и его автор + steps: + - intent: ask_name + - action: utter_name + - intent: ask_creator + - action: utter_creator + - intent: thanks + - action: utter_you_are_welcome + +- story: От NLU к обучению + steps: + - intent: ask_rasa + - action: utter_rasa + - intent: ask_nlu + - action: utter_nlu + - intent: ask_training + - action: utter_training + +- story: Структура и запуск проекта + steps: + - intent: ask_project_structure + - action: utter_project_structure + - intent: ask_docker + - action: utter_docker + - intent: ask_run + - action: utter_run + +- story: Время и дата + steps: + - intent: ask_time + - action: action_show_time + - intent: ask_date + - action: action_show_date + - intent: thanks + - action: utter_you_are_welcome + +- story: Короткий разговор + steps: + - intent: smalltalk_howareyou + - action: utter_howareyou + - intent: smalltalk_joke + - action: utter_joke + - intent: goodbye + - action: utter_goodbye + +- story: Помощь после неудачного ответа + steps: + - intent: feedback_bad + - action: utter_feedback_bad + - intent: help + - action: action_show_capabilities + - intent: ask_nlu + - action: utter_nlu + +- story: Возврат из неподдерживаемой темы + steps: + - intent: out_of_scope + - action: utter_out_of_scope + - intent: ask_rasa + - action: utter_rasa + +- story: Восстановление после непонятного сообщения + steps: + - intent: nlu_fallback + - action: utter_default + - intent: help + - action: action_show_capabilities + +- story: Перезапуск именованного диалога + steps: + - intent: inform_name + entities: + - person_name: Анна + - action: action_remember_name + - intent: ask_restart + - action: utter_restart + - action: action_restart diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff078f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +# Проверка: docker compose run --rm --no-deps rasa data validate --fail-on-warnings +# Обучение: docker compose run --rm --no-deps rasa train +# CLI: docker compose run --rm rasa +services: + rasa: + image: rasa/rasa:3.6.21 + working_dir: /app + volumes: + - ./:/app + command: + - shell + - --endpoints + - endpoints.docker.yml + stdin_open: true + tty: true + depends_on: + action_server: + condition: service_healthy + environment: + RASA_TELEMETRY_ENABLED: "false" + + action_server: + image: rasa/rasa-sdk:3.6.2 + working_dir: /app + volumes: + - ./actions:/app/actions:ro + command: + - start + - --actions + - actions.actions + expose: + - "5055" + healthcheck: + test: + - CMD + - python + - -c + - import urllib.request; urllib.request.urlopen('http://localhost:5055/health', timeout=2) + interval: 5s + timeout: 3s + retries: 10 + start_period: 5s + environment: + RASA_TELEMETRY_ENABLED: "false" diff --git a/domain.yml b/domain.yml new file mode 100644 index 0000000..97384f3 --- /dev/null +++ b/domain.yml @@ -0,0 +1,104 @@ +version: "3.1" + +intents: + - greet + - goodbye + - thanks + - help + - ask_name + - ask_creator + - ask_project + - inform_name + - ask_restart + - ask_rasa + - ask_nlu + - ask_training + - ask_run + - ask_project_structure + - ask_docker + - feedback_bad + - ask_time + - ask_date + - smalltalk_howareyou + - smalltalk_joke + - out_of_scope + - nlu_fallback + +entities: + - person_name + +slots: + user_name: + type: text + influence_conversation: false + mappings: + - type: from_entity + entity: person_name + +responses: + utter_greet: + - text: "Здравствуйте! Я учебный русскоязычный помощник на Rasa. Могу рассказать о проекте, его ML-компонентах и запуске." + - text: "Привет! Спросите меня о проекте, Rasa, NLU, Docker, обучении модели или структуре репозитория." + + utter_goodbye: + - text: "До свидания! Спасибо за разговор." + - text: "До встречи! Буду рад снова ответить на вопросы о проекте." + + utter_you_are_welcome: + - text: "Пожалуйста! Рад быть полезным." + - text: "Не за что. Можем разобрать ещё один вопрос." + + utter_name: + - text: "Меня можно называть Rasa-помощником. Я демонстрирую распознавание русскоязычных намерений, извлечение сущностей и управление диалогом." + + utter_creator: + - text: "Меня создал Хуршид Мухаммадиев в рамках дипломного проекта о разработке диалоговой системы и виртуального помощника." + + utter_project: + - text: "Это учебный проект русскоязычного виртуального помощника. DIETClassifier определяет намерение и извлекает имя пользователя, а правила и диалоговые политики выбирают подготовленный ответ или действие." + + utter_restart: + - text: "Хорошо, начинаем заново. Состояние текущего диалога, включая сохранённое имя, будет очищено." + + utter_rasa: + - text: "Rasa Open Source — платформа для создания диалоговых систем. В этом проекте она объединяет NLU-модель, состояние диалога, правила, истории и пользовательские действия." + + utter_nlu: + - text: "NLU преобразует сообщение в структурированные данные. Intent описывает цель сообщения, а entity выделяет значимое значение: например, в фразе «Меня зовут Анна» intent — inform_name, а Анна — person_name." + + utter_training: + - text: "Сначала данные проверяются командой «rasa data validate», затем «rasa train» обучает NLU и диалоговые политики и сохраняет модель в каталоге models. Качество оценивается только на отдельных тестовых примерах." + + utter_run: + - text: "Для локального CLI запустите action server командой «rasa run actions», а в другом терминале — «rasa shell --endpoints endpoints.yml». В Docker используйте «docker compose run --rm --no-deps rasa train», затем запустите action_server и откройте shell с endpoints.docker.yml." + + utter_project_structure: + - text: "config.yml задаёт NLU-конвейер и политики; domain.yml — intents, entities, slots, ответы и actions; каталог data — обучающие примеры, rules и stories; actions содержит Python-код, а tests — независимые NLU-данные и Core-сценарии." + + utter_docker: + - text: "Docker фиксирует совместимые версии Rasa и Rasa SDK. Docker Compose создаёт общую сеть, в которой CLI-контейнер обращается к action server по имени сервиса." + + utter_feedback_bad: + - text: "Спасибо за замечание. Попробуйте переформулировать запрос или спросите, какие темы я поддерживаю." + + utter_howareyou: + - text: "Спасибо, всё работает штатно. Я готов отвечать на вопросы о проекте." + + utter_joke: + - text: "Почему разработчик доверяет логам больше догадок? Потому что у логов всегда есть запись разговора." + + utter_out_of_scope: + - text: "Я понял запрос, но эта тема находится за пределами моих возможностей. Я отвечаю на вопросы о проекте, Rasa, NLU, Docker, обучении и запуске." + + utter_default: + - text: "Я не уверен, что правильно понял сообщение. Переформулируйте вопрос или спросите о моих возможностях." + +actions: + - action_remember_name + - action_show_capabilities + - action_show_time + - action_show_date + +session_config: + session_expiration_time: 60 + carry_over_slots_to_new_session: false diff --git a/endpoints.docker.yml b/endpoints.docker.yml new file mode 100644 index 0000000..493872b --- /dev/null +++ b/endpoints.docker.yml @@ -0,0 +1,2 @@ +action_endpoint: + url: "http://action_server:5055/webhook" diff --git a/endpoints.yml b/endpoints.yml new file mode 100644 index 0000000..858a463 --- /dev/null +++ b/endpoints.yml @@ -0,0 +1,2 @@ +action_endpoint: + url: "http://127.0.0.1:5055/webhook" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..ee8c21e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==7.4.4 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5caa52d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +rasa==3.6.21 +rasa-sdk==3.6.2 diff --git a/results/README.md b/results/README.md new file mode 100644 index 0000000..9d4a07f --- /dev/null +++ b/results/README.md @@ -0,0 +1,54 @@ +# Evaluation results + +All committed metrics in this directory come from actual runs of the pinned +Rasa environment. Generated model archives are deliberately excluded from Git. +The current v2 procedure can be repeated from the tracked code, data, +configuration, and pinned direct dependencies; bit-for-bit model identity is +not claimed without a complete transitive lock file. + +## Baseline and final runs + +The v1 numbers in `experiments.md` are a historical baseline: its 565-example +corpus snapshot and raw JSON reports were not preserved, so that run is not +reproducible from this repository. Later diagnostic runs may be compared there +while improving only the training data or model configuration. A dataset that +has already been inspected during that process is diagnostic data, not an +untouched estimate of generalisation. + +`final/` contains the selected model's evaluation against the designated NLU +test file. The test split was held out from training, and neither train nor the +model configuration changed after the selected final result. If the test is +later used to guide another iteration, the next result must be described as +diagnostic rather than untouched. The summary also states that the manually +curated/synthetic corpus does not replace evaluation on messages from real +users. + +Run the reproducible pipeline from the repository root on Windows: + +```powershell +.\scripts\train.ps1 +.\scripts\evaluate.ps1 +``` + +`evaluate.ps1` defaults to `tests/nlu_test.yml` and `results/final/`; both paths +can be overridden with `-TestData` and `-OutputDirectory`. It runs the raw Rasa +NLU evaluation, measures the full pipeline with `FallbackClassifier`, evaluates +Core on gold intents using the same model, and then regenerates `summary.md` +from the JSON reports. + +The committed final artifacts are the real outputs used for review: + +- `intent_report.json` and `DIETClassifier_report.json` contain per-class and + aggregate metrics produced by Rasa; +- `intent_errors.json` and `DIETClassifier_errors.json` contain misclassified + examples used for error analysis; +- `*_confusion_matrix.png` and `*_histogram.png` are Rasa's diagnostic plots; +- `summary.md` is generated by `scripts/summarize_results.py` from those JSON + files; +- `runtime_report.json` stores aggregate coverage, fallback rate and selective + accuracy for the complete runtime pipeline without copying message texts. +- `story_report.json` is the Core-only report for ten dialogue scenarios with + gold intents; it is not an end-to-end NLU result. + +Successful-example dumps are intentionally not generated or committed. Never +replace generated metrics with estimates or manually invented values. diff --git a/results/experiments.md b/results/experiments.md new file mode 100644 index 0000000..c832386 --- /dev/null +++ b/results/experiments.md @@ -0,0 +1,62 @@ +# Журнал экспериментов + +Все значения ниже получены реальными запусками Rasa 3.6.21 на Python 3.10.11. +Для DIETClassifier и TEDPolicy задан `random_seed: 42`. Архивы моделей не +хранятся в Git. Текущую v2 и финальную процедуру можно повторить из сохранённых +данных, конфигурации и закреплённых прямых зависимостей; без полного +транзитивного lock-файла побитовая идентичность модели не гарантируется. + +Диагностический набор использовался для анализа ошибок между итерациями, +поэтому его результаты не являются финальной слепой оценкой. Он сохранён в +`tests/nlu_dev.yml`; итоговый отложенный набор — в `tests/nlu_test.yml`. + +| Итерация | Train | Diagnostic | Accuracy | Macro-F1 | Weighted-F1 | `person_name` token/tag F1 | +|---|---:|---:|---:|---:|---:|---:| +| v1: исходный очищенный корпус | 565 | 235 | 74,47% | 74,08% | 74,23% | 87,80% | +| v2: контрастное расширение данных | 795 | 235 | 85,11% | 84,96% | 84,85% | 87,80% | + +## Что изменилось в v2 + +- добавлено 230 новых обучающих примеров без точных пересечений с + диагностическим набором; +- усилены границы `ask_project` / `ask_creator`, `ask_training` / `ask_run`, + `ask_time` / `ask_date` и `ask_name` / `inform_name`; +- добавлены сложные отрицательные примеры для `out_of_scope`; +- расширены контексты извлечения неизвестного `person_name` и отрицательные + контексты с именами собственными; +- по сохранённому журналу конфигурация модели между v1 и v2 не менялась. + +В сохранённом историческом сравнении Macro-F1 вырос на **10,88 процентного +пункта**. Snapshot корпуса из 565 примеров и первичные JSON v1 не сохранились, +поэтому v1 служит исторической точкой отсчёта, но не считается воспроизводимым +артефактом этого репозитория. Наблюдаемый прирост согласуется с влиянием +расширения данных, но заново проверить это объяснение без snapshot v1 нельзя. + +Финальный test split был отложен от обучения. После результатов выбранного +финального прогона train и конфигурация модели не изменялись. Его артефакты +находятся в `results/final/`. + +## Финальная отложенная оценка + +- 235 test-сообщений; +- Raw intent accuracy: **89,36%**; +- Macro-F1 intent: **90,23%**; +- Weighted-F1 intent: **89,26%**; +- token/tag F1 `person_name`: **87,80%**; +- полный pipeline с fallback: **88,94% строгой accuracy**, **97,02% coverage** + принятых intent и **91,67% selective accuracy**. + +Fallback отклонил семь сообщений: шесть с ошибочным raw intent и одно с верным. +Он повысил точность среди принятых сообщений, но снизил строгую общую accuracy +на 0,43 процентного пункта и coverage на 2,98 процентного пункта. + +Статический валидатор подтверждает отсутствие точных пересечений между +train/dev/test, совпадающих train/test entity-шаблонов и повторно использованных +имён. По выбранной текстовой эвристике между train/test и dev/test нет пар +одного intent со score `>= 0,850`. Эта граница чувствительна к метрике и не +исключает семантически близкие формулировки; это техническая проверка, а не +доказательство статистической независимости корпуса. + +> Оба набора подготовлены вручную/синтетически. Эти метрики показывают качество +> на заданном корпусе и не заменяют тестирование на сообщениях реальных +> пользователей. diff --git a/results/final/DIETClassifier_confusion_matrix.png b/results/final/DIETClassifier_confusion_matrix.png new file mode 100644 index 0000000..857b6db Binary files /dev/null and b/results/final/DIETClassifier_confusion_matrix.png differ diff --git a/results/final/DIETClassifier_errors.json b/results/final/DIETClassifier_errors.json new file mode 100644 index 0000000..1179b2b --- /dev/null +++ b/results/final/DIETClassifier_errors.json @@ -0,0 +1,64 @@ +[ + { + "text": "На обращение Темур-Бек я откликнусь", + "entities": [ + { + "start": 13, + "end": 22, + "value": "Темур-Бек", + "entity": "person_name" + } + ], + "predicted_entities": [] + }, + { + "text": "Найди дешёвые билеты в Алматы", + "entities": [], + "predicted_entities": [ + { + "entity": "person_name", + "start": 23, + "end": 29, + "confidence_entity": 0.745909571647644, + "value": "Алматы", + "extractor": "DIETClassifier" + } + ] + }, + { + "text": "Как устроена файловая система Windows?", + "entities": [], + "predicted_entities": [ + { + "entity": "person_name", + "start": 30, + "end": 37, + "confidence_entity": 0.5979303121566772, + "value": "Windows", + "extractor": "DIETClassifier" + } + ] + }, + { + "text": "Какова дата рождения Александра Пушкина?", + "entities": [], + "predicted_entities": [ + { + "entity": "person_name", + "start": 21, + "end": 31, + "confidence_entity": 0.9825685024261475, + "value": "Александра", + "extractor": "DIETClassifier" + }, + { + "entity": "person_name", + "start": 32, + "end": 39, + "confidence_entity": 0.6506542563438416, + "value": "Пушкина", + "extractor": "DIETClassifier" + } + ] + } +] \ No newline at end of file diff --git a/results/final/DIETClassifier_histogram.png b/results/final/DIETClassifier_histogram.png new file mode 100644 index 0000000..364ef05 Binary files /dev/null and b/results/final/DIETClassifier_histogram.png differ diff --git a/results/final/DIETClassifier_report.json b/results/final/DIETClassifier_report.json new file mode 100644 index 0000000..cde46e4 --- /dev/null +++ b/results/final/DIETClassifier_report.json @@ -0,0 +1,28 @@ +{ + "person_name": { + "precision": 0.8181818181818182, + "recall": 0.9473684210526315, + "f1-score": 0.8780487804878049, + "support": 19, + "confused_with": {} + }, + "micro avg": { + "precision": 0.8181818181818182, + "recall": 0.9473684210526315, + "f1-score": 0.8780487804878049, + "support": 19 + }, + "macro avg": { + "precision": 0.8181818181818182, + "recall": 0.9473684210526315, + "f1-score": 0.8780487804878049, + "support": 19 + }, + "weighted avg": { + "precision": 0.8181818181818182, + "recall": 0.9473684210526315, + "f1-score": 0.8780487804878049, + "support": 19 + }, + "accuracy": 0.9954421148587056 +} \ No newline at end of file diff --git a/results/final/intent_confusion_matrix.png b/results/final/intent_confusion_matrix.png new file mode 100644 index 0000000..1f55972 Binary files /dev/null and b/results/final/intent_confusion_matrix.png differ diff --git a/results/final/intent_errors.json b/results/final/intent_errors.json new file mode 100644 index 0000000..9636dc0 --- /dev/null +++ b/results/final/intent_errors.json @@ -0,0 +1,202 @@ +[ + { + "text": "Приятно встретиться с тобой этим утром", + "intent": "greet", + "intent_prediction": { + "name": "help", + "confidence": 0.9932712912559509 + } + }, + { + "text": "Я ценю оказанную поддержку", + "intent": "thanks", + "intent_prediction": { + "name": "help", + "confidence": 0.5510782599449158 + } + }, + { + "text": "Представься, пожалуйста", + "intent": "ask_name", + "intent_prediction": { + "name": "inform_name", + "confidence": 0.935112714767456 + } + }, + { + "text": "Чьей работой стало твоё появление?", + "intent": "ask_creator", + "intent_prediction": { + "name": "thanks", + "confidence": 0.5638586282730103 + } + }, + { + "text": "Можно ли проверить модель без повторного обучения?", + "intent": "ask_run", + "intent_prediction": { + "name": "ask_training", + "confidence": 0.9996166229248047 + } + }, + { + "text": "Какие каталоги есть у Rasa-проекта?", + "intent": "ask_project_structure", + "intent_prediction": { + "name": "ask_rasa", + "confidence": 0.500762403011322 + } + }, + { + "text": "Ты мне не помог", + "intent": "feedback_bad", + "intent_prediction": { + "name": "thanks", + "confidence": 0.795018196105957 + } + }, + { + "text": "В сообщённых сведениях есть ошибка", + "intent": "feedback_bad", + "intent_prediction": { + "name": "ask_date", + "confidence": 0.7298399209976196 + } + }, + { + "text": "Уже поздно или ещё рано?", + "intent": "ask_time", + "intent_prediction": { + "name": "greet", + "confidence": 0.5094811320304871 + } + }, + { + "text": "Определи, сейчас утро, день, вечер или ночь", + "intent": "ask_time", + "intent_prediction": { + "name": "greet", + "confidence": 0.5425835251808167 + } + }, + { + "text": "С тобой нынче всё ладно?", + "intent": "smalltalk_howareyou", + "intent_prediction": { + "name": "help", + "confidence": 0.9866999387741089 + } + }, + { + "text": "Ты как?", + "intent": "smalltalk_howareyou", + "intent_prediction": { + "name": "ask_name", + "confidence": 0.7040160298347473 + } + }, + { + "text": "В каком ты сегодня расположении духа?", + "intent": "smalltalk_howareyou", + "intent_prediction": { + "name": "ask_project_structure", + "confidence": 0.6958075761795044 + } + }, + { + "text": "Пошути, пожалуйста", + "intent": "smalltalk_joke", + "intent_prediction": { + "name": "inform_name", + "confidence": 0.4979470372200012 + } + }, + { + "text": "Разряди обстановку хорошей шуткой", + "intent": "smalltalk_joke", + "intent_prediction": { + "name": "smalltalk_howareyou", + "confidence": 0.5853334069252014 + } + }, + { + "text": "Сколько будет 347 умножить на 29?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "inform_name", + "confidence": 0.9305649995803833 + } + }, + { + "text": "Напиши поздравление с годовщиной свадьбы", + "intent": "out_of_scope", + "intent_prediction": { + "name": "greet", + "confidence": 0.3742619454860687 + } + }, + { + "text": "Как вылечить сильную боль в спине?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "ask_run", + "confidence": 0.46617835760116577 + } + }, + { + "text": "Объясни квантовую запутанность простыми словами", + "intent": "out_of_scope", + "intent_prediction": { + "name": "help", + "confidence": 0.635073721408844 + } + }, + { + "text": "Как завести автомобиль после морозной ночи?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "ask_run", + "confidence": 0.7760034799575806 + } + }, + { + "text": "Подскажи схему вязания тёплого шарфа спицами", + "intent": "out_of_scope", + "intent_prediction": { + "name": "help", + "confidence": 0.6222909688949585 + } + }, + { + "text": "Как устроена файловая система Windows?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "ask_project_structure", + "confidence": 0.9755270481109619 + } + }, + { + "text": "Что означает слово «раса» в биологии?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "ask_nlu", + "confidence": 0.9174363017082214 + } + }, + { + "text": "Как меня зовут?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "inform_name", + "confidence": 0.9968974590301514 + } + }, + { + "text": "Какова дата рождения Александра Пушкина?", + "intent": "out_of_scope", + "intent_prediction": { + "name": "inform_name", + "confidence": 0.7035830616950989 + } + } +] \ No newline at end of file diff --git a/results/final/intent_histogram.png b/results/final/intent_histogram.png new file mode 100644 index 0000000..6f06de8 Binary files /dev/null and b/results/final/intent_histogram.png differ diff --git a/results/final/intent_report.json b/results/final/intent_report.json new file mode 100644 index 0000000..6ccecef --- /dev/null +++ b/results/final/intent_report.json @@ -0,0 +1,194 @@ +{ + "ask_project": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 10, + "confused_with": {} + }, + "ask_date": { + "precision": 0.9090909090909091, + "recall": 1.0, + "f1-score": 0.9523809523809523, + "support": 10, + "confused_with": {} + }, + "goodbye": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 10, + "confused_with": {} + }, + "ask_time": { + "precision": 1.0, + "recall": 0.8, + "f1-score": 0.888888888888889, + "support": 10, + "confused_with": { + "greet": 2 + } + }, + "inform_name": { + "precision": 0.75, + "recall": 1.0, + "f1-score": 0.8571428571428571, + "support": 15, + "confused_with": {} + }, + "ask_project_structure": { + "precision": 0.8181818181818182, + "recall": 0.9, + "f1-score": 0.8571428571428572, + "support": 10, + "confused_with": { + "ask_rasa": 1 + } + }, + "ask_nlu": { + "precision": 0.9090909090909091, + "recall": 1.0, + "f1-score": 0.9523809523809523, + "support": 10, + "confused_with": {} + }, + "ask_creator": { + "precision": 1.0, + "recall": 0.9, + "f1-score": 0.9473684210526316, + "support": 10, + "confused_with": { + "thanks": 1 + } + }, + "ask_restart": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 10, + "confused_with": {} + }, + "thanks": { + "precision": 0.8181818181818182, + "recall": 0.9, + "f1-score": 0.8571428571428572, + "support": 10, + "confused_with": { + "help": 1 + } + }, + "ask_training": { + "precision": 0.9090909090909091, + "recall": 1.0, + "f1-score": 0.9523809523809523, + "support": 10, + "confused_with": {} + }, + "smalltalk_joke": { + "precision": 1.0, + "recall": 0.8, + "f1-score": 0.888888888888889, + "support": 10, + "confused_with": { + "smalltalk_howareyou": 1, + "inform_name": 1 + } + }, + "ask_rasa": { + "precision": 0.9090909090909091, + "recall": 1.0, + "f1-score": 0.9523809523809523, + "support": 10, + "confused_with": {} + }, + "smalltalk_howareyou": { + "precision": 0.875, + "recall": 0.7, + "f1-score": 0.7777777777777777, + "support": 10, + "confused_with": { + "help": 1, + "ask_name": 1 + } + }, + "ask_name": { + "precision": 0.9, + "recall": 0.9, + "f1-score": 0.9, + "support": 10, + "confused_with": { + "inform_name": 1 + } + }, + "ask_docker": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 10, + "confused_with": {} + }, + "greet": { + "precision": 0.75, + "recall": 0.9, + "f1-score": 0.8181818181818182, + "support": 10, + "confused_with": { + "help": 1 + } + }, + "out_of_scope": { + "precision": 1.0, + "recall": 0.6666666666666666, + "f1-score": 0.8, + "support": 30, + "confused_with": { + "inform_name": 3, + "help": 2 + } + }, + "feedback_bad": { + "precision": 1.0, + "recall": 0.8, + "f1-score": 0.888888888888889, + "support": 10, + "confused_with": { + "thanks": 1, + "ask_date": 1 + } + }, + "help": { + "precision": 0.6666666666666666, + "recall": 1.0, + "f1-score": 0.8, + "support": 10, + "confused_with": {} + }, + "ask_run": { + "precision": 0.8181818181818182, + "recall": 0.9, + "f1-score": 0.8571428571428572, + "support": 10, + "confused_with": { + "ask_training": 1 + } + }, + "accuracy": 0.8936170212765957, + "macro avg": { + "precision": 0.9063131313131313, + "recall": 0.9126984126984128, + "f1-score": 0.9022899962749588, + "support": 235 + }, + "weighted avg": { + "precision": 0.9109606705351387, + "recall": 0.8936170212765957, + "f1-score": 0.8926238872487474, + "support": 235 + }, + "micro avg": { + "precision": 0.8936170212765957, + "recall": 0.8936170212765957, + "f1-score": 0.8936170212765957, + "support": 235 + } +} \ No newline at end of file diff --git a/results/final/runtime_report.json b/results/final/runtime_report.json new file mode 100644 index 0000000..f11e9ea --- /dev/null +++ b/results/final/runtime_report.json @@ -0,0 +1,111 @@ +{ + "total": 235, + "correct": 209, + "accuracy": 0.8893617021276595, + "raw_correct": 210, + "raw_accuracy": 0.8936170212765957, + "fallback_count": 7, + "fallback_raw_correct_count": 1, + "fallback_raw_incorrect_count": 6, + "fallback_rate": 0.029787234042553193, + "accepted_count": 228, + "coverage": 0.9702127659574468, + "accepted_correct": 209, + "selective_accuracy": 0.9166666666666666, + "fallback_expected_distribution": { + "ask_time": 2, + "out_of_scope": 2, + "ask_nlu": 1, + "ask_project_structure": 1, + "smalltalk_joke": 1 + }, + "top_confusions": [ + { + "expected": "out_of_scope", + "predicted": "inform_name", + "count": 3 + }, + { + "expected": "ask_time", + "predicted": "nlu_fallback", + "count": 2 + }, + { + "expected": "out_of_scope", + "predicted": "help", + "count": 2 + }, + { + "expected": "out_of_scope", + "predicted": "nlu_fallback", + "count": 2 + }, + { + "expected": "ask_creator", + "predicted": "thanks", + "count": 1 + }, + { + "expected": "ask_name", + "predicted": "inform_name", + "count": 1 + }, + { + "expected": "ask_nlu", + "predicted": "nlu_fallback", + "count": 1 + }, + { + "expected": "ask_project_structure", + "predicted": "nlu_fallback", + "count": 1 + }, + { + "expected": "ask_run", + "predicted": "ask_training", + "count": 1 + }, + { + "expected": "feedback_bad", + "predicted": "ask_date", + "count": 1 + }, + { + "expected": "feedback_bad", + "predicted": "thanks", + "count": 1 + }, + { + "expected": "greet", + "predicted": "help", + "count": 1 + }, + { + "expected": "out_of_scope", + "predicted": "ask_nlu", + "count": 1 + }, + { + "expected": "out_of_scope", + "predicted": "ask_project_structure", + "count": 1 + }, + { + "expected": "out_of_scope", + "predicted": "ask_run", + "count": 1 + } + ], + "metadata": { + "model_file": "models/russian-assistant-v2.tar.gz", + "model_sha256": "1b99ab0a48dfcc4f33b55d46dac2be9272ef53fe45d4923eede76e806ab4309d", + "nlu_file": "tests/nlu_test.yml", + "nlu_sha256": "dc21a5e70c9c47a01fa9cb07586923be2454e4f248c7b75dd352b40e773c117a", + "config_file": "config.yml", + "config_sha256": "119b7931dc61ca5c44638a9bf9b8f2f1618f094e86444c02c75028735e7d4481", + "python_version": "3.10.11", + "rasa_version": "3.6.21", + "rasa_sdk_version": "3.6.2", + "generated_at_utc": "2026-07-22T09:55:17Z" + } +} diff --git a/results/final/story_report.json b/results/final/story_report.json new file mode 100644 index 0000000..f526ab5 --- /dev/null +++ b/results/final/story_report.json @@ -0,0 +1,171 @@ +{ + "utter_joke": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_name": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_creator": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_docker": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_show_time": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_out_of_scope": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_restart": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_feedback_bad": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_restart": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_project_structure": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_show_date": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_training": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_greet": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_rasa": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_show_capabilities": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 2 + }, + "utter_howareyou": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_default": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_listen": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 23 + }, + "utter_project": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_you_are_welcome": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_run": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "action_remember_name": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_goodbye": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "utter_nlu": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 1 + }, + "accuracy": 1.0, + "macro avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 47 + }, + "weighted avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 47 + }, + "micro avg": { + "precision": 1.0, + "recall": 1.0, + "f1-score": 1.0, + "support": 47 + }, + "conversation_accuracy": { + "accuracy": 1.0, + "correct": 10, + "with_warnings": 0, + "total": 10 + } +} \ No newline at end of file diff --git a/results/final/summary.md b/results/final/summary.md new file mode 100644 index 0000000..1071d30 --- /dev/null +++ b/results/final/summary.md @@ -0,0 +1,82 @@ +# Итоги NLU-оценки + +- Дата запуска (UTC): **2026-07-22** +- Окружение: **Python 3.10.11, Rasa 3.6.21, Rasa SDK 3.6.2** +- Данные: **795 train / 235 test сообщений** +- Конфигурация: **DIETClassifier/TEDPolicy, 100 эпох, seed 42** +- Оценено сообщений: **235** +- Accuracy: **89.36%** +- Macro-F1: **90.23%** +- Weighted-F1: **89.26%** +- Ошибок intent: **25** +- F1 `person_name` на уровне токенов/тегов: **87.80%** +- Сообщений с ошибкой entity: **4** + +> `rasa test nlu` запускает NLU-конвейер, но перед подсчётом intent-метрик восстанавливает исходный top-intent из ranking, если сработал FallbackClassifier. Поэтому итоговое fallback-решение отдельно измеряется полным runtime-конвейером. + +## Поведение полного pipeline + +- Raw accuracy до fallback: **89.36%** +- Строгая intent accuracy после fallback: **88.94%** +- Coverage — доля сообщений с принятым intent (не `nlu_fallback`): **97.02%** +- Selective accuracy среди принятых intent: **91.67%** +- Доля fallback: **2.98%** +- Fallback отклонил **7** сообщений: **1** с верным raw intent и **6** с ошибочным raw intent. + +> Это компромисс abstention: fallback может повысить точность среди принятых intent, одновременно снижая coverage и, если он отклоняет верный raw intent, строгую общую accuracy. + +## Rasa Core + +- Диалоговые истории: **10/10** +- Действия: **47/47** +- Action accuracy: **100.00%** + +> Это Core-only тесты с заранее заданными gold intents; NLU здесь не проверяется. Шаги `action_listen` составляют **23/47** всех проверенных действий. + +## Метрики по intent + +| Intent | Precision | Recall | F1 | Support | +|---|---:|---:|---:|---:| +| `smalltalk_howareyou` | 0.875 | 0.700 | 0.778 | 10 | +| `help` | 0.667 | 1.000 | 0.800 | 10 | +| `out_of_scope` | 1.000 | 0.667 | 0.800 | 30 | +| `greet` | 0.750 | 0.900 | 0.818 | 10 | +| `inform_name` | 0.750 | 1.000 | 0.857 | 15 | +| `ask_project_structure` | 0.818 | 0.900 | 0.857 | 10 | +| `ask_run` | 0.818 | 0.900 | 0.857 | 10 | +| `thanks` | 0.818 | 0.900 | 0.857 | 10 | +| `ask_time` | 1.000 | 0.800 | 0.889 | 10 | +| `feedback_bad` | 1.000 | 0.800 | 0.889 | 10 | +| `smalltalk_joke` | 1.000 | 0.800 | 0.889 | 10 | +| `ask_name` | 0.900 | 0.900 | 0.900 | 10 | +| `ask_creator` | 1.000 | 0.900 | 0.947 | 10 | +| `ask_date` | 0.909 | 1.000 | 0.952 | 10 | +| `ask_nlu` | 0.909 | 1.000 | 0.952 | 10 | +| `ask_rasa` | 0.909 | 1.000 | 0.952 | 10 | +| `ask_training` | 0.909 | 1.000 | 0.952 | 10 | +| `ask_docker` | 1.000 | 1.000 | 1.000 | 10 | +| `ask_project` | 1.000 | 1.000 | 1.000 | 10 | +| `ask_restart` | 1.000 | 1.000 | 1.000 | 10 | +| `goodbye` | 1.000 | 1.000 | 1.000 | 10 | + +## Частые смешения + +| Истинный intent | Предсказанный intent | Число | +|---|---|---:| +| `out_of_scope` | `inform_name` | 3 | +| `ask_time` | `greet` | 2 | +| `out_of_scope` | `ask_run` | 2 | +| `out_of_scope` | `help` | 2 | +| `greet` | `help` | 1 | +| `thanks` | `help` | 1 | +| `ask_name` | `inform_name` | 1 | +| `ask_creator` | `thanks` | 1 | +| `ask_run` | `ask_training` | 1 | +| `ask_project_structure` | `ask_rasa` | 1 | +| `feedback_bad` | `thanks` | 1 | +| `feedback_bad` | `ask_date` | 1 | +| `smalltalk_howareyou` | `help` | 1 | +| `smalltalk_howareyou` | `ask_name` | 1 | +| `smalltalk_howareyou` | `ask_project_structure` | 1 | + +> Финальный test-набор подготовлен отдельно и не использовался при обучении. Он остаётся вручную/синтетически составленным, поэтому результат не заменяет проверку на сообщениях реальных пользователей. diff --git a/scripts/chat.ps1 b/scripts/chat.ps1 new file mode 100644 index 0000000..c35fec7 --- /dev/null +++ b/scripts/chat.ps1 @@ -0,0 +1,105 @@ +[CmdletBinding()] +param( + [string]$Model +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" +$env:RASA_TELEMETRY_ENABLED = "false" + +$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +$Python = Join-Path $ProjectRoot ".venv-rasa\Scripts\python.exe" + +if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { + throw @" +Rasa environment was not found at .venv-rasa. +Create it with Python 3.10 and install the pinned dependencies: + py -3.10 -m venv .venv-rasa + .\.venv-rasa\Scripts\python.exe -m pip install -r requirements.txt +"@ +} + +Set-Location -LiteralPath $ProjectRoot + +$EndpointsPath = Join-Path $ProjectRoot "endpoints.yml" +if (-not (Test-Path -LiteralPath $EndpointsPath -PathType Leaf)) { + throw "Required project file is missing: endpoints.yml" +} + +if ([string]::IsNullOrWhiteSpace($Model)) { + $ModelsDirectory = Join-Path $ProjectRoot "models" + if (-not (Test-Path -LiteralPath $ModelsDirectory -PathType Container)) { + throw "No models directory was found. Run scripts/train.ps1 first." + } + + $ModelFile = Get-ChildItem -LiteralPath $ModelsDirectory -Filter "*.tar.gz" -File | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if ($null -eq $ModelFile) { + throw "No trained model was found in models/. Run scripts/train.ps1 first." + } + + $ModelPath = $ModelFile.FullName +} +else { + $ModelPath = (Resolve-Path -LiteralPath $Model).Path +} + +$ActionProcess = $null + +try { + Write-Host "Starting local Rasa action server on http://127.0.0.1:5055 ..." + $ActionProcess = Start-Process ` + -FilePath $Python ` + -ArgumentList @("-m", "rasa_sdk", "--actions", "actions.actions", "--port", "5055", "--quiet") ` + -WorkingDirectory $ProjectRoot ` + -PassThru ` + -WindowStyle Hidden + + $ActionServerReady = $false + for ($Attempt = 1; $Attempt -le 30; $Attempt++) { + if ($ActionProcess.HasExited) { + throw "The Rasa action server exited before becoming ready." + } + + try { + $HealthResponse = Invoke-WebRequest ` + -Uri "http://127.0.0.1:5055/health" ` + -UseBasicParsing ` + -TimeoutSec 1 + if ($HealthResponse.StatusCode -eq 200) { + $ActionServerReady = $true + break + } + } + catch { + Start-Sleep -Seconds 1 + } + } + + if (-not $ActionServerReady) { + throw "The Rasa action server did not become ready within 30 seconds." + } + + $RasaArguments = @( + "shell", + "--model", $ModelPath, + "--endpoints", $EndpointsPath + ) + + Write-Host "Running: rasa shell --model --endpoints " + & $Python -m rasa @RasaArguments + if ($LASTEXITCODE -ne 0) { + throw "Rasa shell failed with exit code $LASTEXITCODE." + } +} +finally { + if (($null -ne $ActionProcess) -and (-not $ActionProcess.HasExited)) { + Stop-Process -Id $ActionProcess.Id -Force -ErrorAction SilentlyContinue + $ActionProcess.WaitForExit() + } +} diff --git a/scripts/evaluate.ps1 b/scripts/evaluate.ps1 new file mode 100644 index 0000000..9177022 --- /dev/null +++ b/scripts/evaluate.ps1 @@ -0,0 +1,174 @@ +[CmdletBinding()] +param( + [string]$Model, + [string]$TestData = "tests/nlu_test.yml", + [string]$OutputDirectory = "results/final" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" +$env:RASA_TELEMETRY_ENABLED = "false" + +$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +$Python = Join-Path $ProjectRoot ".venv-rasa\Scripts\python.exe" + +if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { + throw @" +Rasa environment was not found at .venv-rasa. +Create it with Python 3.10 and install the pinned development dependencies: + py -3.10 -m venv .venv-rasa + .\.venv-rasa\Scripts\python.exe -m pip install -r requirements-dev.txt +"@ +} + +Set-Location -LiteralPath $ProjectRoot + +$RequiredFiles = @( + "config.yml", + "domain.yml", + "data/nlu.yml", + "scripts/summarize_results.py", + "scripts/evaluate_runtime_nlu.py", + "tests/test_stories.yml" +) +foreach ($RequiredFile in $RequiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot $RequiredFile) -PathType Leaf)) { + throw "Required project file is missing: $RequiredFile" + } +} + +if ([string]::IsNullOrWhiteSpace($Model)) { + $ModelsDirectory = Join-Path $ProjectRoot "models" + if (-not (Test-Path -LiteralPath $ModelsDirectory -PathType Container)) { + throw "No models directory was found. Run scripts/train.ps1 first." + } + + $ModelFile = Get-ChildItem -LiteralPath $ModelsDirectory -Filter "*.tar.gz" -File | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + + if ($null -eq $ModelFile) { + throw "No trained model was found in models/. Run scripts/train.ps1 first." + } + + $ModelPath = $ModelFile.FullName +} +else { + $ModelCandidate = if ([System.IO.Path]::IsPathRooted($Model)) { + $Model + } + else { + Join-Path $ProjectRoot $Model + } + + if (-not (Test-Path -LiteralPath $ModelCandidate -PathType Leaf)) { + throw "Model file was not found: $Model" + } + + $ModelPath = (Resolve-Path -LiteralPath $ModelCandidate).Path +} + +if ([string]::IsNullOrWhiteSpace($TestData)) { + throw "TestData must point to a Rasa NLU YAML file." +} + +$TestDataCandidate = if ([System.IO.Path]::IsPathRooted($TestData)) { + $TestData +} +else { + Join-Path $ProjectRoot $TestData +} + +if (-not (Test-Path -LiteralPath $TestDataCandidate -PathType Leaf)) { + throw "NLU test data file was not found: $TestData" +} +$TestDataPath = (Resolve-Path -LiteralPath $TestDataCandidate).Path + +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + throw "OutputDirectory must not be empty." +} + +$OutputCandidate = if ([System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory +} +else { + Join-Path $ProjectRoot $OutputDirectory +} +$OutputPath = [System.IO.Path]::GetFullPath($OutputCandidate) + +if (Test-Path -LiteralPath $OutputPath -PathType Leaf) { + throw "OutputDirectory points to a file: $OutputPath" +} +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null +$OutputPath = (Resolve-Path -LiteralPath $OutputPath).Path + +$DomainPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "domain.yml")).Path +$ConfigPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "config.yml")).Path +$TrainDataPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "data/nlu.yml")).Path +$SummarizerPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "scripts/summarize_results.py")).Path +$RuntimeEvaluatorPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "scripts/evaluate_runtime_nlu.py")).Path +$CoreStoriesPath = (Resolve-Path -LiteralPath (Join-Path $ProjectRoot "tests/test_stories.yml")).Path +$SummaryPath = Join-Path $OutputPath "summary.md" +$RuntimeReportPath = Join-Path $OutputPath "runtime_report.json" +$CoreOutputPath = Join-Path $OutputPath "core" +$CoreReportPath = Join-Path $CoreOutputPath "story_report.json" +$PublishedCoreReportPath = Join-Path $OutputPath "story_report.json" + +$RasaArguments = @( + "test", + "nlu", + "--model", $ModelPath, + "--nlu", $TestDataPath, + "--domain", $DomainPath, + "--out", $OutputPath +) + +Write-Host "Running held-out NLU evaluation against $TestDataPath" +Write-Host "Running: rasa $($RasaArguments -join ' ')" +& $Python -m rasa @RasaArguments +if ($LASTEXITCODE -ne 0) { + throw "Rasa NLU evaluation failed with exit code $LASTEXITCODE." +} + +Write-Host "Measuring the complete NLU pipeline including FallbackClassifier" +& $Python $RuntimeEvaluatorPath ` + $ModelPath ` + --nlu $TestDataPath ` + --config $ConfigPath ` + --output $RuntimeReportPath +if ($LASTEXITCODE -ne 0) { + throw "Runtime NLU evaluation failed with exit code $LASTEXITCODE." +} + +Write-Host "Running Rasa Core evaluation against $CoreStoriesPath" +$CoreArguments = @( + "test", + "core", + "--model", $ModelPath, + "--stories", $CoreStoriesPath, + "--out", $CoreOutputPath +) +Write-Host "Running: rasa $($CoreArguments -join ' ')" +& $Python -m rasa @CoreArguments +if ($LASTEXITCODE -ne 0) { + throw "Rasa Core evaluation failed with exit code $LASTEXITCODE." +} +if (-not (Test-Path -LiteralPath $CoreReportPath -PathType Leaf)) { + throw "Rasa Core evaluation did not produce story_report.json." +} +Copy-Item -LiteralPath $CoreReportPath -Destination $PublishedCoreReportPath -Force + +Write-Host "Building Markdown summary from the generated reports" +& $Python $SummarizerPath ` + $OutputPath ` + --train-data $TrainDataPath ` + --test-data $TestDataPath ` + --output $SummaryPath +if ($LASTEXITCODE -ne 0) { + throw "Evaluation summary generation failed with exit code $LASTEXITCODE." +} + +Write-Host "Evaluation artifacts: $OutputPath" diff --git a/scripts/evaluate_runtime_nlu.py b/scripts/evaluate_runtime_nlu.py new file mode 100644 index 0000000..385899f --- /dev/null +++ b/scripts/evaluate_runtime_nlu.py @@ -0,0 +1,374 @@ +"""Evaluate the complete Rasa NLU pipeline, including fallback decisions. + +Unlike ``rasa test nlu``, this script sends every held-out example through a +loaded ``Agent``. The resulting report therefore reflects the configured +``FallbackClassifier`` and stores aggregates only, never example texts. +""" + +from __future__ import annotations + +import argparse +import asyncio +import hashlib +import importlib.metadata +import json +import logging +import platform +from collections import Counter +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + + +FALLBACK_INTENT = "nlu_fallback" +TOP_CONFUSIONS_LIMIT = 15 + + +class InputValidationError(ValueError): + """Raised when the evaluation inputs do not have the expected shape.""" + + +def _safe_ratio(numerator: int, denominator: int) -> float: + """Return a JSON-friendly ratio, including for an empty denominator.""" + + return numerator / denominator if denominator else 0.0 + + +def _examples_from_block(value: Any, context: str) -> list[str]: + """Extract annotated example strings from a Rasa YAML examples block.""" + + if isinstance(value, str): + examples: list[str] = [] + for line_number, line in enumerate(value.splitlines(), start=1): + if not line.strip(): + continue + stripped = line.strip() + if not stripped.startswith("-"): + raise InputValidationError( + f"{context}, line {line_number}: each multiline example must " + "start with '-'." + ) + example = stripped[1:].strip() + if not example: + raise InputValidationError( + f"{context}, line {line_number}: example text is empty." + ) + examples.append(example) + return examples + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + examples = [] + for example_index, item in enumerate(value, start=1): + if isinstance(item, str): + example = item.strip() + elif isinstance(item, Mapping): + text = item.get("text") + if not isinstance(text, str): + raise InputValidationError( + f"{context}, example {example_index}: list entries must " + "contain a string 'text' field." + ) + example = text.strip() + else: + raise InputValidationError( + f"{context}, example {example_index}: expected a string or " + "a mapping with a 'text' field." + ) + + if not example: + raise InputValidationError( + f"{context}, example {example_index}: example text is empty." + ) + examples.append(example) + return examples + + raise InputValidationError( + f"{context}: 'examples' must be a multiline string or a list." + ) + + +def load_labeled_examples(nlu_path: Path) -> list[tuple[str, str]]: + """Load ``(expected_intent, plain_text)`` pairs from Rasa NLU YAML.""" + + from rasa.shared.nlu.training_data.entities_parser import replace_entities + + try: + document = yaml.safe_load(nlu_path.read_text(encoding="utf-8")) + except UnicodeDecodeError as exc: + raise InputValidationError(f"NLU file is not valid UTF-8: {nlu_path}") from exc + except yaml.YAMLError as exc: + raise InputValidationError(f"Invalid YAML in {nlu_path}: {exc}") from exc + + if not isinstance(document, Mapping): + raise InputValidationError("NLU YAML root must be a mapping.") + + version = document.get("version") + if not isinstance(version, str) or not version.strip(): + raise InputValidationError("NLU YAML must contain a non-empty string 'version'.") + + nlu_blocks = document.get("nlu") + if not isinstance(nlu_blocks, list): + raise InputValidationError("NLU YAML field 'nlu' must be a list.") + if not nlu_blocks: + raise InputValidationError("NLU YAML field 'nlu' must not be empty.") + + labeled_examples: list[tuple[str, str]] = [] + for block_index, block in enumerate(nlu_blocks, start=1): + context = f"NLU block {block_index}" + if not isinstance(block, Mapping): + raise InputValidationError(f"{context} must be a mapping.") + + intent = block.get("intent") + if not isinstance(intent, str) or not intent.strip(): + raise InputValidationError( + f"{context} must contain a non-empty string 'intent'." + ) + intent = intent.strip() + if intent == FALLBACK_INTENT: + raise InputValidationError( + f"{context} uses reserved runtime intent '{FALLBACK_INTENT}' as " + "a gold label. Label the message with its expected domain intent " + "instead." + ) + + if "examples" not in block: + raise InputValidationError(f"{context} is missing 'examples'.") + annotated_examples = _examples_from_block(block["examples"], context) + if not annotated_examples: + raise InputValidationError(f"{context} contains no examples.") + + for annotated_text in annotated_examples: + plain_text = replace_entities(annotated_text).strip() + if not plain_text: + raise InputValidationError( + f"{context} contains an example that is empty after removing " + "entity markup." + ) + labeled_examples.append((intent, plain_text)) + + if not labeled_examples: + raise InputValidationError("NLU YAML contains no labeled examples.") + return labeled_examples + + +def _raw_candidate_name(parsed: Mapping[str, Any], predicted: str) -> str: + """Return the pre-fallback top intent for one runtime prediction.""" + + if predicted != FALLBACK_INTENT: + return predicted + + ranking = parsed.get("intent_ranking") + if not isinstance(ranking, Sequence) or isinstance(ranking, (str, bytes)): + raise RuntimeError( + "Rasa fallback prediction did not contain an intent_ranking sequence." + ) + + for candidate in ranking: + if not isinstance(candidate, Mapping): + continue + name = candidate.get("name") + if isinstance(name, str) and name and name != FALLBACK_INTENT: + return name + + raise RuntimeError( + "Rasa fallback prediction did not contain a raw non-fallback candidate." + ) + + +async def evaluate( + model_path: Path, labeled_examples: list[tuple[str, str]] +) -> dict[str, Any]: + """Run all examples through one loaded agent and aggregate predictions.""" + + from rasa.core.agent import Agent + + agent = Agent.load(str(model_path)) + if not agent.is_ready(): + raise RuntimeError(f"Rasa Agent could not load a ready model from {model_path}") + + correct = 0 + raw_correct = 0 + fallback_count = 0 + fallback_raw_correct_count = 0 + fallback_raw_incorrect_count = 0 + accepted_correct = 0 + fallback_expected: Counter[str] = Counter() + confusions: Counter[tuple[str, str]] = Counter() + + for expected, text in labeled_examples: + parsed = await agent.parse_message(text) + intent_data = parsed.get("intent") + if not isinstance(intent_data, Mapping): + raise RuntimeError("Rasa parser returned no intent mapping.") + predicted = intent_data.get("name") + if not isinstance(predicted, str) or not predicted: + raise RuntimeError("Rasa parser returned an invalid intent name.") + + raw_predicted = _raw_candidate_name(parsed, predicted) + is_correct = predicted == expected + is_raw_correct = raw_predicted == expected + if is_correct: + correct += 1 + if is_raw_correct: + raw_correct += 1 + + if predicted == FALLBACK_INTENT: + fallback_count += 1 + fallback_expected[expected] += 1 + if is_raw_correct: + fallback_raw_correct_count += 1 + else: + fallback_raw_incorrect_count += 1 + else: + if is_correct: + accepted_correct += 1 + + if not is_correct: + confusions[(expected, predicted)] += 1 + + total = len(labeled_examples) + accepted_count = total - fallback_count + ordered_confusions = sorted( + confusions.items(), key=lambda item: (-item[1], item[0][0], item[0][1]) + )[:TOP_CONFUSIONS_LIMIT] + ordered_fallback_distribution = sorted( + fallback_expected.items(), key=lambda item: (-item[1], item[0]) + ) + + return { + "total": total, + "correct": correct, + "accuracy": _safe_ratio(correct, total), + "raw_correct": raw_correct, + "raw_accuracy": _safe_ratio(raw_correct, total), + "fallback_count": fallback_count, + "fallback_raw_correct_count": fallback_raw_correct_count, + "fallback_raw_incorrect_count": fallback_raw_incorrect_count, + "fallback_rate": _safe_ratio(fallback_count, total), + "accepted_count": accepted_count, + "coverage": _safe_ratio(accepted_count, total), + "accepted_correct": accepted_correct, + "selective_accuracy": _safe_ratio(accepted_correct, accepted_count), + "fallback_expected_distribution": { + intent: count for intent, count in ordered_fallback_distribution + }, + "top_confusions": [ + {"expected": expected, "predicted": predicted, "count": count} + for (expected, predicted), count in ordered_confusions + ], + } + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Evaluate a full Rasa NLU pipeline, including FallbackClassifier, " + "without storing source example texts." + ) + ) + parser.add_argument("MODEL", type=Path, help="Path to a trained Rasa model") + parser.add_argument( + "--nlu", + type=Path, + default=Path("tests/nlu_test.yml"), + help="Held-out Rasa NLU YAML (default: tests/nlu_test.yml)", + ) + parser.add_argument( + "--config", + type=Path, + default=Path("config.yml"), + help="Rasa configuration file to fingerprint (default: config.yml)", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("results/final/runtime_report.json"), + help=( + "Aggregate JSON report path " + "(default: results/final/runtime_report.json)" + ), + ) + return parser + + +def _configure_rasa_logging() -> None: + """Keep Rasa from logging evaluated message texts at debug level.""" + + from rasa.utils.common import configure_logging_and_warnings + from rasa.utils.log_utils import configure_structlog + + configure_logging_and_warnings(log_level=logging.ERROR) + configure_structlog(log_level=logging.ERROR) + + +def _portable_path(path: Path) -> str: + """Return a repository-relative path without leaking a local home path.""" + + resolved = path.resolve() + try: + return resolved.relative_to(Path.cwd().resolve()).as_posix() + except ValueError: + return resolved.name + + +def _sha256(path: Path) -> str: + """Return the lowercase SHA-256 digest for a file.""" + + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def main() -> int: + parser = _build_parser() + args = parser.parse_args() + + model_path: Path = args.MODEL.expanduser() + nlu_path: Path = args.nlu.expanduser() + config_path: Path = args.config.expanduser() + output_path: Path = args.output.expanduser() + + if not model_path.is_file(): + parser.error(f"model file does not exist: {model_path}") + if not nlu_path.is_file(): + parser.error(f"NLU file does not exist: {nlu_path}") + if not config_path.is_file(): + parser.error(f"config file does not exist: {config_path}") + + try: + labeled_examples = load_labeled_examples(nlu_path) + except (OSError, InputValidationError) as exc: + parser.error(str(exc)) + + _configure_rasa_logging() + report = asyncio.run(evaluate(model_path, labeled_examples)) + report["metadata"] = { + "model_file": _portable_path(model_path), + "model_sha256": _sha256(model_path), + "nlu_file": _portable_path(nlu_path), + "nlu_sha256": _sha256(nlu_path), + "config_file": _portable_path(config_path), + "config_sha256": _sha256(config_path), + "python_version": platform.python_version(), + "rasa_version": importlib.metadata.version("rasa"), + "rasa_sdk_version": importlib.metadata.version("rasa-sdk"), + "generated_at_utc": datetime.now(timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z"), + } + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print(f"Wrote aggregate runtime NLU report to {output_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/summarize_results.py b/scripts/summarize_results.py new file mode 100644 index 0000000..ffc15f8 --- /dev/null +++ b/scripts/summarize_results.py @@ -0,0 +1,255 @@ +"""Create a compact Markdown summary from Rasa NLU evaluation artifacts.""" + +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import platform +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import yaml + + +def load_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def percent(value: float) -> str: + return f"{value * 100:.2f}%" + + +def count_nlu_examples(path: Path) -> int: + document = yaml.safe_load(path.read_text(encoding="utf-8")) + blocks = document.get("nlu", []) if isinstance(document, dict) else [] + count = 0 + for block in blocks: + if not isinstance(block, dict) or "intent" not in block: + continue + examples = block.get("examples", "") + if isinstance(examples, str): + count += sum( + 1 for line in examples.splitlines() if line.strip().startswith("-") + ) + elif isinstance(examples, list): + count += len(examples) + return count + + +def package_version(name: str) -> str: + try: + return importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + return "не установлена" + + +def build_summary( + results_dir: Path, + train_data: Path, + test_data: Path, +) -> str: + intent_report = load_json(results_dir / "intent_report.json") + intent_errors = load_json(results_dir / "intent_errors.json") + entity_report = load_json(results_dir / "DIETClassifier_report.json") + entity_errors = load_json(results_dir / "DIETClassifier_errors.json") + + macro = intent_report["macro avg"] + weighted = intent_report["weighted avg"] + accuracy = intent_report["accuracy"] + support = int(macro["support"]) + + class_rows = [] + ignored = {"accuracy", "macro avg", "weighted avg", "micro avg"} + for name, metrics in intent_report.items(): + if name in ignored or not isinstance(metrics, dict): + continue + class_rows.append( + ( + name, + float(metrics["precision"]), + float(metrics["recall"]), + float(metrics["f1-score"]), + int(metrics["support"]), + ) + ) + class_rows.sort(key=lambda row: (row[3], row[0])) + + confusions = Counter( + (error["intent"], error["intent_prediction"]["name"]) + for error in intent_errors + ) + person_name_metrics = entity_report.get("person_name") + if not isinstance(person_name_metrics, dict): + # Backward-compatible fallback for older single-entity Rasa reports. + person_name_metrics = entity_report["macro avg"] + runtime_report_path = results_dir / "runtime_report.json" + runtime_report = ( + load_json(runtime_report_path) if runtime_report_path.is_file() else None + ) + core_report_path = results_dir / "story_report.json" + core_report = load_json(core_report_path) if core_report_path.is_file() else None + train_count = count_nlu_examples(train_data) + test_count = count_nlu_examples(test_data) + + lines = [ + "# Итоги NLU-оценки", + "", + f"- Дата запуска (UTC): **{datetime.now(timezone.utc):%Y-%m-%d}**", + f"- Окружение: **Python {platform.python_version()}, " + f"Rasa {package_version('rasa')}, Rasa SDK {package_version('rasa-sdk')}**", + f"- Данные: **{train_count} train / {test_count} test сообщений**", + "- Конфигурация: **DIETClassifier/TEDPolicy, 100 эпох, seed 42**", + f"- Оценено сообщений: **{support}**", + f"- Accuracy: **{percent(accuracy)}**", + f"- Macro-F1: **{percent(macro['f1-score'])}**", + f"- Weighted-F1: **{percent(weighted['f1-score'])}**", + f"- Ошибок intent: **{len(intent_errors)}**", + f"- F1 `person_name` на уровне токенов/тегов: " + f"**{percent(person_name_metrics['f1-score'])}**", + f"- Сообщений с ошибкой entity: **{len(entity_errors)}**", + "", + "> `rasa test nlu` запускает NLU-конвейер, но перед подсчётом " + "intent-метрик восстанавливает исходный top-intent из ranking, если " + "сработал FallbackClassifier. Поэтому итоговое fallback-решение отдельно " + "измеряется полным runtime-конвейером.", + "", + ] + + if isinstance(runtime_report, dict): + raw_accuracy = float(runtime_report.get("raw_accuracy", accuracy)) + fallback_count = int(runtime_report.get("fallback_count", 0)) + fallback_raw_correct = runtime_report.get("fallback_raw_correct_count") + fallback_raw_incorrect = runtime_report.get("fallback_raw_incorrect_count") + lines.extend( + [ + "## Поведение полного pipeline", + "", + f"- Raw accuracy до fallback: **{percent(raw_accuracy)}**", + f"- Строгая intent accuracy после fallback: " + f"**{percent(float(runtime_report['accuracy']))}**", + f"- Coverage — доля сообщений с принятым intent " + f"(не `nlu_fallback`): " + f"**{percent(float(runtime_report['coverage']))}**", + f"- Selective accuracy среди принятых intent: " + f"**{percent(float(runtime_report['selective_accuracy']))}**", + f"- Доля fallback: " + f"**{percent(float(runtime_report['fallback_rate']))}**", + ] + ) + if fallback_raw_correct is not None and fallback_raw_incorrect is not None: + lines.append( + f"- Fallback отклонил **{fallback_count}** сообщений: " + f"**{int(fallback_raw_correct)}** с верным raw intent и " + f"**{int(fallback_raw_incorrect)}** с ошибочным raw intent." + ) + lines.extend( + [ + "", + "> Это компромисс abstention: fallback может повысить точность " + "среди принятых intent, одновременно снижая coverage и, если он " + "отклоняет верный raw intent, строгую общую accuracy.", + "", + ] + ) + + if isinstance(core_report, dict): + conversation = core_report.get("conversation_accuracy", {}) + total_actions = int(core_report["macro avg"]["support"]) + correct_actions = round(float(core_report["accuracy"]) * total_actions) + action_listen = core_report.get("action_listen") + action_listen_support = ( + int(action_listen.get("support", 0)) + if isinstance(action_listen, dict) + else None + ) + lines.extend( + [ + "## Rasa Core", + "", + f"- Диалоговые истории: **{conversation.get('correct', 0)}/" + f"{conversation.get('total', 0)}**", + f"- Действия: **{correct_actions}/{total_actions}**", + f"- Action accuracy: **{percent(float(core_report['accuracy']))}**", + ] + ) + core_caveat = ( + "> Это Core-only тесты с заранее заданными gold intents; NLU здесь " + "не проверяется." + ) + if action_listen_support is not None: + core_caveat += ( + f" Шаги `action_listen` составляют " + f"**{action_listen_support}/{total_actions}** всех проверенных " + "действий." + ) + lines.extend(["", core_caveat, ""]) + + lines.extend( + [ + "## Метрики по intent", + "", + "| Intent | Precision | Recall | F1 | Support |", + "|---|---:|---:|---:|---:|", + ] + ) + lines.extend( + f"| `{name}` | {precision:.3f} | {recall:.3f} | {f1:.3f} | {count} |" + for name, precision, recall, f1, count in class_rows + ) + + lines.extend( + [ + "", + "## Частые смешения", + "", + "| Истинный intent | Предсказанный intent | Число |", + "|---|---|---:|", + ] + ) + lines.extend( + f"| `{expected}` | `{predicted}` | {count} |" + for (expected, predicted), count in confusions.most_common(15) + ) + + lines.extend( + [ + "", + "> Финальный test-набор подготовлен отдельно и не использовался " + "при обучении. Он остаётся вручную/синтетически составленным, " + "поэтому результат не заменяет проверку на сообщениях реальных " + "пользователей.", + "", + ] + ) + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("results_dir", nargs="?", default="results/final") + parser.add_argument("--output", help="Optional Markdown output path") + parser.add_argument("--train-data", default="data/nlu.yml") + parser.add_argument("--test-data", default="tests/nlu_test.yml") + args = parser.parse_args() + + summary = build_summary( + Path(args.results_dir), + Path(args.train_data), + Path(args.test_data), + ) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + print(summary) + if args.output: + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(summary, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/train.ps1 b/scripts/train.ps1 new file mode 100644 index 0000000..682c616 --- /dev/null +++ b/scripts/train.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [switch]$Force +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" +$env:RASA_TELEMETRY_ENABLED = "false" + +$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +$Python = Join-Path $ProjectRoot ".venv-rasa\Scripts\python.exe" + +if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { + throw @" +Rasa environment was not found at .venv-rasa. +Create it with Python 3.10 and install the pinned dependencies: + py -3.10 -m venv .venv-rasa + .\.venv-rasa\Scripts\python.exe -m pip install -r requirements.txt +"@ +} + +Set-Location -LiteralPath $ProjectRoot + +$RequiredFiles = @("config.yml", "domain.yml", "endpoints.yml") +foreach ($RequiredFile in $RequiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot $RequiredFile) -PathType Leaf)) { + throw "Required project file is missing: $RequiredFile" + } +} + +$RasaArguments = @( + "train", + "--config", "config.yml", + "--domain", "domain.yml", + "--data", "data", + "--out", "models", + "--endpoints", "endpoints.yml" +) + +if ($Force) { + $RasaArguments += "--force" +} + +Write-Host "Running: rasa $($RasaArguments -join ' ')" +& $Python -m rasa @RasaArguments +if ($LASTEXITCODE -ne 0) { + throw "Rasa training failed with exit code $LASTEXITCODE." +} + +$LatestModel = Get-ChildItem -LiteralPath (Join-Path $ProjectRoot "models") -Filter "*.tar.gz" -File | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + +if ($null -eq $LatestModel) { + throw "Training completed without producing a model in models/." +} + +Write-Host "Model created: $($LatestModel.FullName)" diff --git a/scripts/validate.ps1 b/scripts/validate.ps1 new file mode 100644 index 0000000..fe21b29 --- /dev/null +++ b/scripts/validate.ps1 @@ -0,0 +1,53 @@ +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$env:PYTHONUTF8 = "1" +$env:PYTHONIOENCODING = "utf-8" +$env:RASA_TELEMETRY_ENABLED = "false" + +$ProjectRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path +$Python = Join-Path $ProjectRoot ".venv-rasa\Scripts\python.exe" + +if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { + throw @" +Rasa environment was not found at .venv-rasa. +Create it with Python 3.10 and install the pinned development dependencies: + py -3.10 -m venv .venv-rasa + .\.venv-rasa\Scripts\python.exe -m pip install -r requirements-dev.txt +"@ +} + +Set-Location -LiteralPath $ProjectRoot + +$RequiredFiles = @( + "config.yml", + "domain.yml", + "endpoints.yml", + "tests/static_validate.py" +) + +foreach ($RequiredFile in $RequiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot $RequiredFile) -PathType Leaf)) { + throw "Required project file is missing: $RequiredFile" + } +} + +Write-Host "Running: rasa data validate --domain domain.yml --data data --fail-on-warnings" +& $Python -m rasa data validate ` + --domain "domain.yml" ` + --data "data" ` + --fail-on-warnings +if ($LASTEXITCODE -ne 0) { + throw "Rasa data validation failed with exit code $LASTEXITCODE." +} + +Write-Host "Running: python tests/static_validate.py" +& $Python "tests/static_validate.py" +if ($LASTEXITCODE -ne 0) { + throw "Static project validation failed with exit code $LASTEXITCODE." +} + +Write-Host "Validation completed successfully." diff --git a/tests/nlu_dev.yml b/tests/nlu_dev.yml new file mode 100644 index 0000000..786a7a7 --- /dev/null +++ b/tests/nlu_dev.yml @@ -0,0 +1,302 @@ +version: "3.1" + +# Диагностический набор использовался для анализа ошибок между итерациями. +# Финальная отложенная оценка находится в tests/nlu_test.yml. +nlu: +- intent: greet + examples: | + - здравия и успехов + - моё почтение + - приветствую вас в новом диалоге + - начнём со знакомства + - здравия желаю + - хочу начать с приветствия + - приятно встретиться + - начинаю разговор с приветствия + - алло, помощник, ты здесь + - на связи новый пользователь + +- intent: goodbye + examples: | + - благодарю за беседу, я завершаю работу + - пора заканчивать нашу встречу + - оставим вопросы до другого раза + - желаю удачи, до нового разговора + - я больше не буду тебя задерживать + - можно закрыть беседу + - увидимся в следующем сеансе + - откланиваюсь + - на сегодня закончили + - покидаю чат + +- intent: thanks + examples: | + - твоя подсказка пригодилась + - я получил нужное объяснение, благодарю + - ты здорово меня выручил + - теперь всё ясно, моя благодарность + - признателен за содержательный разбор + - помощь пришлась очень кстати + - благодарю, вопрос решён + - ценная подсказка, спасибо тебе + - ответ оказался полезным, большое спасибо + - выражаю признательность за содействие + +- intent: help + examples: | + - сориентируй меня по доступным разделам + - не знаю, с чего начать общение + - предложи темы для разговора + - проведи краткий обзор своих навыков + - какие операции доступны пользователю + - где посмотреть руководство по боту + - объясни правила взаимодействия с системой + - что входит в твою компетенцию + - покажи меню функций + - какие задачи тебе можно поручить + +- intent: ask_name + examples: | + - как подписан мой виртуальный собеседник + - сообщи своё обозначение + - представь себя пользователю + - каково имя системы + - хотелось бы знать, с кем ведётся беседа + - какое обращение ты предпочитаешь + - кем мне считать отвечающего помощника + - подскажи название бота + - идентифицируй себя + - кто находится на другом конце диалога + +- intent: ask_creator + examples: | + - назови человека, выполнившего разработку + - чья работа лежит в основе ассистента + - укажи создателя программного решения + - кем написана эта система + - кто подготовил исходный код + - хочу узнать исполнителя дипломной работы + - сообщи фамилию автора бота + - кто стоял за созданием помощника + - какой разработчик реализовал проект + - назови человека, которому принадлежит эта разработка + +- intent: ask_project + examples: | + - сформулируй основную идею выполненной работы + - какой замысел лежит в основе ассистента + - опиши предмет дипломного исследования + - какую пользу должна показать разработка + - что было главной целью автора проекта + - дай общее резюме выполненной системы + - какие результаты представляет этот диплом + - в рамках какой темы создан помощник + - раскрой суть проектной работы + - какую концепцию диалоговых систем здесь исследуют + +- intent: inform_name + examples: | + - для начала запомни, что собеседника зовут [Максим](person_name) + - в карточке пользователя укажи [Ольга](person_name) + - подпиши меня как [Сергей Белов](person_name) + - я зарегистрирована под именем [Юлия Андреева](person_name) + - правильное написание моего имени — [Нур-Мухаммад](person_name) + - задай мне обращение [Анастасия](person_name) + - имя для этого сеанса: [Вадим](person_name) + - пожалуйста, считай моим именем [Гульнара Каримова](person_name) + - правильное обращение ко мне — [Пётр Алексеевич](person_name) + - мой собеседник может называть меня [Эмиль](person_name) + - внеси в память имя [Ксения](person_name) + - я отзываюсь на имя [Андрей Соколов](person_name) + - пусть в разговоре моим именем будет [Лейла](person_name) + - для обращения подойдёт [Степан](person_name) + - зафиксируй для беседы имя [Майя-Лин](person_name) + +- intent: ask_restart + examples: | + - создай беседу без предыдущих сообщений + - требуется полностью обновить состояние чата + - можешь забыть всё сказанное ранее + - открой для меня чистый сеанс + - хочу сбросить сохранённый ход общения + - верни бота в состояние первого запуска + - удали накопленный контекст этой переписки + - переключи нас на новую беседу + - перезапуск нужен без сохранения истории + - очистим сведения из текущего разговора + +- intent: ask_rasa + examples: | + - к какому типу программ относится Rasa + - расскажи, что представляет собой этот фреймворк + - почему для диалогового помощника взяли платформу Rasa + - какое место занимает Rasa в общей системе + - перечисли ключевые части фреймворка + - для каких проектов подходит Rasa Open Source + - в чём преимущество Rasa для учебного ассистента + - охарактеризуй платформу, на которой построен бот + - какие возможности предоставляет сама Rasa + - объясни назначение фреймворка без деталей обучения + +- intent: ask_nlu + examples: | + - каким способом фраза получает метку намерения + - объясни разницу между целью запроса и извлечённым значением + - что делает модуль понимания пользовательской речи + - каким образом предложение относят к нужному классу + - зачем отмечать имена внутри обучающих фраз + - какой этап отвечает за смысловой разбор текста + - что возвращает анализатор языка диалоговому менеджеру + - как распознаются полезные значения в сообщении + - раскрой понятия intent и entity на примере + - по какому принципу бот понимает категорию реплики + +- intent: ask_training + examples: | + - опиши, как из размеченного корпуса получается классификатор + - что алгоритм делает с примерами во время тренировки + - когда требуется заново построить модель + - каким должен быть набор данных для качественного обучения + - где настраиваются параметры тренировочного процесса + - как обучающие фразы используются компонентом DIET + - что создаётся после завершения процедуры обучения + - почему нельзя оценивать модель на её тренировочных данных + - какие шаги проходит подготовка модели к работе + - как изменится обучение после добавления нового класса + +- intent: ask_run + examples: | + - как перейти от готового архива модели к разговору + - подскажи команду для интерактивной проверки бота + - что включить, чтобы принимать сообщения в консоли + - каким образом поднять уже подготовленный ассистент + - как открыть режим непосредственного общения + - опиши запуск компонентов после обучения + - где проверить ответы работающей модели + - как отдельно стартовать исполнитель custom actions + - что необходимо запустить для локальной демонстрации + - как проверить систему вручную через shell + +- intent: ask_project_structure + examples: | + - нарисуй карту директорий репозитория + - в какой части проекта объявлена сущность имени + - откуда Rasa читает примеры пользовательских фраз + - найди файл с шаблонами ответов + - где располагается реализация действий на Python + - какой конфигурационный файл описывает компоненты NLU + - поясни распределение кода и данных по каталогам + - в каком документе указан адрес сервера actions + - где искать правила переходов между репликами + - какие основные элементы находятся в корне проекта + +- intent: ask_docker + examples: | + - почему окружение проекта упаковано в образы + - какую задачу выполняет Compose при сборке сервисов + - поясни разделение приложения на два контейнера + - как контейнеризация обеспечивает одинаковый запуск + - для чего сервисам внутренняя сеть Docker + - что означает зафиксированный тег образа + - где описаны зависимости контейнерного окружения + - почему actions не находятся в одном процессе с Rasa + - какую роль играют проброшенные порты + - расскажи, как Compose объединяет части системы + +- intent: feedback_bad + examples: | + - предыдущая реакция не соответствует смыслу фразы + - я ожидал совсем другое объяснение + - исправь результат, он получился неточным + - классификация моего запроса явно ошибочна + - приведённые сведения не отвечают на вопрос + - такой ответ только запутывает + - помощник выбрал неподходящий вариант реакции + - качество объяснения меня разочаровало + - повтори по-другому, сейчас ничего не ясно + - ты неверно определил, чего я хотел + +- intent: ask_time + examples: | + - можно свериться с ташкентскими часами + - выведи показание времени для UTC+5 + - сколько минут после текущего часа + - мне нужны часы на данный момент + - сообщи временную отметку без календаря + - что сейчас показывают часы в городе + - нужна временная отметка по местному поясу + - подскажи актуальные показания часов + - узнай время для ташкентского пояса + - какой час наступил прямо сейчас + +- intent: ask_date + examples: | + - какой лист календаря соответствует этому дню + - перечисли нынешние число, месяц и год + - сообщи календарные данные на данный момент + - что за число сегодня по местному календарю + - напомни месяц и день сегодняшнего дня + - выведи дату для часового пояса Ташкента + - какое число следует записать в документе сегодня + - назови сегодняшние календарные реквизиты + - какой датой отмечен текущий день + - мне требуется число этого месяца и год + +- intent: smalltalk_howareyou + examples: | + - как проходит твоя виртуальная жизнь + - готов ли помощник сегодня работать + - расскажи о своём самочувствии + - как складывается твой рабочий день + - давно не виделись, как ты там + - надеюсь, система чувствует себя прекрасно + - какое настроение у моего собеседника + - всё ли спокойно в твоём цифровом мире + - как ты поживал без меня + - бот сегодня в хорошем расположении духа + +- intent: smalltalk_joke + examples: | + - удиви меня остроумной репликой + - есть ли у тебя забавный анекдот для беседы + - устрой небольшую минуту смеха + - хочу проверить твоё чувство юмора + - поделись весёлой историей про код + - произнеси что-либо, способное рассмешить + - разряди обстановку забавным ответом + - подкинь хорошую шутейку + - пора добавить в диалог немного веселья + - умеешь смешно шутить + +- intent: out_of_scope + examples: | + - какая температура будет завтра утром + - подбери авиабилеты до Дубая + - научи меня играть на гитаре + - составь меню на неделю + - найди результаты теннисного турнира + - сколько белка содержится в курице + - объясни правила получения визы + - переведи деньги на другую карту + - расскажи последние политические события + - помоги выбрать университет для поступления + - напиши программу сортировки на Java + - какая планета ближе всего к Солнцу + - отредактируй фотографию для паспорта + - найди дешёвый мобильный тариф + - составь маршрут путешествия по Италии + - когда приедет автобус номер двадцать четыре + - подскажи упражнения для больной спины + - создай презентацию по истории + - сколько будет сто сорок семь умножить на восемь + - закажи букет цветов с доставкой + - проверь наличие товара в магазине + - подбери рецепт без глютена + - расскажи биографию Альберта Эйнштейна + - найди прогноз землетрясений + - помоги настроить домашний роутер + - сочини поздравление с юбилеем + - определи породу собаки по описанию + - узнай результаты анализов в клинике + - рассчитай налог с заработной платы + - включи кондиционер на двадцать градусов diff --git a/tests/nlu_test.yml b/tests/nlu_test.yml new file mode 100644 index 0000000..8226b5c --- /dev/null +++ b/tests/nlu_test.yml @@ -0,0 +1,302 @@ +version: "3.1" + +# Отложенный набор не входит в train; его результаты не использовались для +# настройки финальной модели. Пересечения и пороговые метрики проверяются статически. +nlu: + - intent: greet + examples: | + - Я заглянул поздороваться — приветствую! + - Приятно встретиться с тобой этим утром + - Здравствуйте, рад начать беседу + - Приветствую тебя + - Рад нашей вечерней встрече, помощник + - Хей, давай поговорим + - Ну здравствуй, цифровой собеседник + - Всем доброго дня + - Я здесь, начнём? + - Здорово, помощник + + - intent: goodbye + examples: | + - Буду прощаться, счастливо оставаться + - Пока, спасибо за беседу + - На этом закончим + - Мне пора, до встречи + - На сегодня всё, хорошего дня + - Увидимся в следующий раз + - Завершаю разговор + - Закрываю чат до нашей следующей беседы + - Ладно, я ухожу + - Можно прощаться + + - intent: thanks + examples: | + - Ты выручил, премного благодарен + - Большое спасибо за ответ + - Прими мою искреннюю благодарность + - Я ценю оказанную поддержку + - Ты здорово помог + - После твоего ответа всё прояснилось, благодарю + - Твоя помощь пришлась кстати, спасибо + - Большое человеческое спасибо тебе + - Признателен за такое понятное объяснение + - Ты дал полезную подсказку, огромное спасибо + + - intent: help + examples: | + - Озвучь круг задач, с которыми к тебе можно прийти + - Покажи список возможностей + - Как тобой пользоваться? + - С какими вопросами можно обращаться? + - Подскажи доступные команды + - Мне нужна помощь по работе с ботом + - С чего начать диалог с тобой? + - Расскажи, чем можешь помочь + - Какие функции у тебя есть? + - Дай краткую инструкцию по общению + + - intent: ask_name + examples: | + - Какое имя подставить вместо обращения «бот»? + - У виртуального собеседника предусмотрено имя? + - Представься, пожалуйста + - Кто ты такой? + - Какое обращение к тебе будет правильным? + - Сообщи своё имя как бота + - Ты какой бот? + - Можно узнать твоё имя? + - Каким именем ты представляешься пользователям? + - Расскажи о своей идентичности + + - intent: ask_creator + examples: | + - Кому принадлежит разработка этого помощника? + - Кто твой разработчик? + - Назови автора этого бота + - Чьей работой стало твоё появление? + - Кто работал над твоим созданием? + - Чей это программный проект? + - Кто написал твою логику? + - Что можешь сообщить об авторе своей разработки? + - Как зовут разработчика системы? + - Кто является автором помощника? + + - intent: ask_project + examples: | + - В чём цель дипломного проекта? + - Для чего создан этот дипломный бот? + - Каково назначение проекта? + - Какую задачу решает эта дипломная работа? + - Расскажи об идее проекта + - Зачем понадобилась эта система? + - Какой результат должен показать диплом? + - Какую практическую идею диплома воплощает этот бот? + - Какова практическая цель разработки? + - О чём этот дипломный проект? + + - intent: inform_name + examples: | + - Разреши представиться: я [Анна](person_name) + - Если понадобится обратиться лично, моё имя — [Мехринисо](person_name) + - Для начала представлюсь — [Дилноза](person_name) + - Моё имя для общения — [Алексей](person_name) + - При знакомстве я представляюсь как [Малика](person_name) + - Перед тобой [Евгений](person_name), будем знакомы + - В этой беседе моё имя [Светлана](person_name) + - Для знакомства, я [Бахтиёр](person_name) + - [Анна-Мария](person_name) — так звучит моё имя + - Будем знакомы, перед тобой [Али-Акбар](person_name) + - По документам я [Саида Каримова](person_name) + - Я представлюсь полностью: [Отабек Юсупов](person_name) + - Пришло время познакомиться: я [Нодира Усманова](person_name) + - На обращение [Темур-Бек](person_name) я откликнусь + - Сохрани моё имя: [Александр Сергеев](person_name) + + - intent: ask_restart + examples: | + - Сбрось текущую беседу и открой новую + - Очисти контекст разговора + - Давай с чистого листа + - Забудь предыдущие сообщения + - Перезапусти нашу беседу + - Хочу начать новую сессию + - Сбрось историю текущего диалога + - Верни разговор в начальное состояние + - Обнули контекст, пожалуйста + - Можно всё начать сначала? + + - intent: ask_rasa + examples: | + - Объясни, какую технологию называют Rasa + - Для чего нужна платформа Rasa? + - Какую роль Rasa играет в этом боте? + - Объясни назначение Rasa + - Rasa — это библиотека или сервис? + - Зачем проекту используется Rasa? + - Что делает фреймворк Rasa? + - Расскажи, как Rasa участвует в обработке сообщений + - На чём основана Rasa? + - Какие задачи в системе выполняет Rasa? + + - intent: ask_nlu + examples: | + - Что такое NLU? + - Как бот понимает смысл текста? + - Объясни разницу между интентом и сущностью + - Как определяется намерение пользователя? + - Что называют entity в NLU? + - Каким образом из сообщения извлекаются сущности? + - За что отвечает модуль понимания языка? + - Как фраза преобразуется в intent? + - Расскажи про распознавание интентов + - Что происходит на этапе анализа пользовательского текста? + + - intent: ask_training + examples: | + - Как обучить модель с нуля? + - Какие данные нужны для обучения бота? + - Опиши процесс тренировки NLU + - Что происходит до получения готовой модели? + - Как запустить обучение проекта? + - Из каких этапов состоит подготовка модели? + - Где берутся примеры для тренировки? + - Нужно ли переобучать модель после изменения данных? + - Как формируется обученная модель Rasa? + - Расскажи о подготовке датасета и обучении + + - intent: ask_run + examples: | + - Как запустить уже обученную модель? + - Чем проверить готового бота? + - Какая команда запускает Rasa после обучения? + - Как протестировать сохранённую модель? + - Что нужно сделать, чтобы поговорить с обученным ботом? + - Как поднять проект для локальной проверки? + - Где проверить ответы готовой модели? + - Как запустить чат с существующей моделью? + - Можно ли проверить модель без повторного обучения? + - Опиши запуск бота в режиме проверки + + - intent: ask_project_structure + examples: | + - Как устроена структура проекта? + - За что отвечают файлы в репозитории? + - Какие каталоги есть у Rasa-проекта? + - Где находятся настройки и данные? + - Расшифруй роль ключевых файлов проекта + - В какой папке лежат модели? + - Что обычно хранится в каталоге actions? + - Покажи схему файлов проекта + - Где искать конфигурацию Rasa? + - Расскажи, как организованы директории + + - intent: ask_docker + examples: | + - Как запустить проект в Docker? + - Для чего нужен Docker Compose? + - Какие контейнеры используются системой? + - Как сервисы общаются внутри docker-сети? + - Какие порты нужно открыть для контейнеров? + - Где описана конфигурация Compose? + - Как собрать Docker-образ бота? + - Почему контейнер Rasa не видит action server? + - Как проверить запущенные сервисы Docker? + - Объясни контейнерную схему проекта + + - intent: feedback_bad + examples: | + - Это неправильный ответ + - Ты мне не помог + - Ответ не соответствует вопросу + - Нет, это совсем не то + - В сообщённых сведениях есть ошибка + - Ты неправильно меня понял + - Такой ответ бесполезен + - Разобраться не получилось: твой ответ увёл в сторону + - Исправься, ответ ошибочный + - Это не решает мою проблему + + - intent: ask_time + examples: | + - Что показывают часы в эту минуту? + - Сообщи актуальные часы и минуты + - Хочу сверить часы — назови время + - Какое время на часах? + - Можешь назвать точное время? + - Уже поздно или ещё рано? + - Скажи, который час прямо сейчас + - Хочу узнать местное время + - Определи, сейчас утро, день, вечер или ночь + - Покажи время на данный момент + + - intent: ask_date + examples: | + - Какой календарный день наступил сегодня? + - Заполни сегодняшнюю строку календаря: число, месяц и год + - Какой сегодня день? + - Подскажи дату на сегодня + - Какой сейчас год, месяц и день? + - Покажи сегодняшнюю дату + - Что у нас сегодня по календарю? + - Можешь сказать, какое нынче число? + - Какая дата стоит сейчас? + - Уточни сегодняшние месяц и число + + - intent: smalltalk_howareyou + examples: | + - Как у тебя дела? + - Как настроение? + - Ты сегодня в порядке? + - Как жизнь у цифрового помощника? + - С тобой нынче всё ладно? + - Удачно ли проходит твой день? + - Есть ли перемены в твоих делах? + - Ты как? + - Каково твоё виртуальное самочувствие? + - В каком ты сегодня расположении духа? + + - intent: smalltalk_joke + examples: | + - Расскажи анекдот + - Пошути, пожалуйста + - Знаешь смешную историю? + - Хочу улыбнуться — придумай что-нибудь забавное + - Можешь рассказать шутку? + - Хочу услышать что-нибудь забавное + - Разряди обстановку хорошей шуткой + - Есть свежий анекдот? + - Брось остроумную реплику + - Давай короткую шутку + + - intent: out_of_scope + examples: | + - Какая погода завтра в Самарканде? + - Переведи слово «дружба» на японский + - Сколько будет 347 умножить на 29? + - Подбери рецепт плова без мяса + - Кто выиграл вчерашний футбольный матч? + - Закажи мне такси до аэропорта + - Найди дешёвые билеты в Алматы + - Включи музыку для концентрации + - Поставь будильник на шесть утра + - Напомни позвонить врачу вечером + - Какой сейчас курс доллара к суму? + - Расскажи последние мировые новости + - Посоветуй сериал на выходные + - Напиши поздравление с годовщиной свадьбы + - Как вылечить сильную боль в спине? + - Можешь составить договор аренды квартиры? + - Где ближайшая круглосуточная аптека? + - Проверь статус посылки по номеру отслеживания + - Проложи маршрут до железнодорожного вокзала + - Подбери смартфон дешевле трёх миллионов сумов + - Объясни квантовую запутанность простыми словами + - Кто написал роман «Преступление и наказание»? + - Как завести автомобиль после морозной ночи? + - Перезапусти мой ноутбук + - Подскажи схему вязания тёплого шарфа спицами + - Как устроена файловая система Windows? + - Что означает слово «раса» в биологии? + - Как меня зовут? + - Сколько времени варить рис? + - Какова дата рождения Александра Пушкина? diff --git a/tests/static_validate.py b/tests/static_validate.py new file mode 100644 index 0000000..fb4b4ba --- /dev/null +++ b/tests/static_validate.py @@ -0,0 +1,806 @@ +"""Static consistency checks for the Russian Rasa assistant. + +The validator checks the project schema, the independent train/test NLU +corpora, dialogue references, custom actions, and pinned container versions. +It intentionally does not replace ``rasa data validate`` or trained-model +evaluation. +""" + +from __future__ import annotations + +import ast +import json +import re +import sys +import unicodedata +from collections import Counter, defaultdict +from difflib import SequenceMatcher +from itertools import combinations +from pathlib import Path +from typing import Any, Iterable, Mapping, Sequence + +try: + import yaml +except ModuleNotFoundError: + print("STATIC VALIDATION: FAILED") + print(" - PyYAML is not installed; install the dependencies from requirements.txt") + raise SystemExit(2) + + +ROOT = Path(__file__).resolve().parents[1] + +EXPECTED_INTENTS = { + "greet", + "goodbye", + "thanks", + "help", + "ask_name", + "ask_creator", + "ask_project", + "inform_name", + "ask_restart", + "ask_rasa", + "ask_nlu", + "ask_training", + "ask_run", + "ask_project_structure", + "ask_docker", + "feedback_bad", + "ask_time", + "ask_date", + "smalltalk_howareyou", + "smalltalk_joke", + "out_of_scope", +} + +SYSTEM_INTENTS = {"nlu_fallback"} + +REQUIRED_FILES = ( + "requirements.txt", + "config.yml", + "domain.yml", + "endpoints.yml", + "docker-compose.yml", + "actions/__init__.py", + "actions/actions.py", + "data/nlu.yml", + "data/rules.yml", + "data/stories.yml", + "tests/nlu_dev.yml", + "tests/nlu_test.yml", + "tests/test_stories.yml", + "README.md", + ".gitignore", +) + +SCHEMA_31_FILES = ( + "domain.yml", + "data/nlu.yml", + "data/rules.yml", + "data/stories.yml", + "tests/nlu_dev.yml", + "tests/nlu_test.yml", + "tests/test_stories.yml", +) + +BUILT_IN_ACTIONS = { + "action_back", + "action_deactivate_loop", + "action_default_ask_affirmation", + "action_default_ask_rephrase", + "action_default_fallback", + "action_extract_slots", + "action_listen", + "action_restart", + "action_revert_fallback_events", + "action_session_start", + "action_two_stage_fallback", + "action_unlikely_intent", +} + +INLINE_ENTITY_PATTERN = re.compile( + r"\[(?P[^\]]+)\]\((?P[A-Za-z_][A-Za-z0-9_.-]*)\)" +) +JSON_ENTITY_PATTERN = re.compile(r"\[(?P[^\]]+)\](?P\{[^{}]+\})") + + +class ValidationContext: + def __init__(self) -> None: + self.errors: list[str] = [] + self.documents: dict[str, Any] = {} + + def error(self, message: str) -> None: + self.errors.append(message) + + def load_yaml(self, relative_path: str) -> Any: + path = ROOT / relative_path + if relative_path in self.documents: + return self.documents[relative_path] + if not path.is_file(): + return None + try: + with path.open("r", encoding="utf-8") as source: + document = yaml.safe_load(source) + except (OSError, UnicodeError, yaml.YAMLError) as exc: + self.error(f"{relative_path}: invalid YAML: {exc}") + document = None + self.documents[relative_path] = document + return document + + +def normalize_text(text: str) -> str: + """Normalize examples for duplicate and leakage checks.""" + + text = INLINE_ENTITY_PATTERN.sub(lambda match: match.group("value"), text) + text = JSON_ENTITY_PATTERN.sub(lambda match: match.group("value"), text) + text = unicodedata.normalize("NFKC", text).casefold().replace("ё", "е") + return " ".join(re.findall(r"\w+", text, flags=re.UNICODE)) + + +def normalize_name(text: str) -> str: + return normalize_text(text) + + +def normalize_entity_template(text: str) -> str: + """Normalize wording while masking entity values for leakage checks.""" + + text = INLINE_ENTITY_PATTERN.sub("__entity__", text) + text = JSON_ENTITY_PATTERN.sub("__entity__", text) + text = unicodedata.normalize("NFKC", text).casefold().replace("ё", "е") + return " ".join(re.findall(r"\w+", text, flags=re.UNICODE)) + + +def string_set(values: Any, field: str, context: ValidationContext) -> set[str]: + if values is None: + return set() + if not isinstance(values, list): + context.error(f"{field}: expected a list") + return set() + + result: set[str] = set() + for value in values: + if isinstance(value, str): + result.add(value) + elif isinstance(value, Mapping) and len(value) == 1: + key = next(iter(value)) + if isinstance(key, str): + result.add(key) + else: + context.error(f"{field}: mapping key must be a string: {value!r}") + else: + context.error(f"{field}: unsupported list item: {value!r}") + return result + + +def parse_example_block( + raw_examples: Any, + location: str, + context: ValidationContext, +) -> list[str]: + if isinstance(raw_examples, str): + result: list[str] = [] + for line_number, line in enumerate(raw_examples.splitlines(), start=1): + stripped = line.strip() + if not stripped: + continue + if not stripped.startswith("-"): + context.error( + f"{location}: example line {line_number} must start with '-': {line!r}" + ) + continue + example = stripped[1:].strip() + if not example: + context.error(f"{location}: empty example on line {line_number}") + continue + result.append(example) + return result + + if isinstance(raw_examples, list): + result = [] + for index, value in enumerate(raw_examples, start=1): + if not isinstance(value, str) or not value.strip(): + context.error(f"{location}: invalid example #{index}: {value!r}") + continue + result.append(value.strip()) + return result + + context.error(f"{location}: examples must be a block string or a list") + return [] + + +def nlu_examples( + document: Any, + relative_path: str, + context: ValidationContext, +) -> dict[str, list[str]]: + if not isinstance(document, Mapping): + context.error(f"{relative_path}: top-level YAML value must be a mapping") + return {} + items = document.get("nlu") + if not isinstance(items, list): + context.error(f"{relative_path}: top-level 'nlu' must be a list") + return {} + + result: dict[str, list[str]] = {} + block_counts: Counter[str] = Counter() + for index, item in enumerate(items, start=1): + if not isinstance(item, Mapping): + context.error(f"{relative_path}: nlu item #{index} must be a mapping") + continue + intent = item.get("intent") + if intent is None: + # Rasa also allows regex, lookup and synonym entries in this list. + continue + if not isinstance(intent, str) or not intent: + context.error(f"{relative_path}: nlu item #{index} has an invalid intent") + continue + block_counts[intent] += 1 + result.setdefault(intent, []).extend( + parse_example_block( + item.get("examples"), + f"{relative_path}:{intent}", + context, + ) + ) + + repeated = sorted(intent for intent, count in block_counts.items() if count > 1) + if repeated: + context.error( + f"{relative_path}: intents are split across repeated blocks: " + + ", ".join(repeated) + ) + return result + + +def duplicate_examples( + examples_by_intent: Mapping[str, Sequence[str]], +) -> dict[str, list[tuple[str, str]]]: + grouped: defaultdict[str, list[tuple[str, str]]] = defaultdict(list) + for intent, examples in examples_by_intent.items(): + for example in examples: + grouped[normalize_text(example)].append((intent, example)) + return { + normalized: occurrences + for normalized, occurrences in grouped.items() + if normalized and len(occurrences) > 1 + } + + +def entity_annotations(text: str, location: str, context: ValidationContext) -> list[tuple[str, str]]: + annotations = [ + (match.group("value"), match.group("entity")) + for match in INLINE_ENTITY_PATTERN.finditer(text) + ] + for match in JSON_ENTITY_PATTERN.finditer(text): + try: + metadata = json.loads(match.group("meta")) + except json.JSONDecodeError as exc: + context.error(f"{location}: invalid JSON entity annotation: {exc}") + continue + entity = metadata.get("entity") if isinstance(metadata, Mapping) else None + if not isinstance(entity, str): + context.error(f"{location}: JSON entity annotation has no string 'entity'") + continue + annotations.append((match.group("value"), entity)) + return annotations + + +def validate_entities( + datasets: Mapping[str, Mapping[str, Sequence[str]]], + declared_entities: set[str], + context: ValidationContext, +) -> tuple[dict[str, int], dict[str, int]]: + names_by_split: dict[str, set[str]] = { + split: set() for split in datasets + } + counts: dict[str, int] = {split: 0 for split in datasets} + + for split, dataset in datasets.items(): + name_values = names_by_split[split] + for intent, examples in dataset.items(): + for index, example in enumerate(examples, start=1): + location = f"{split}:{intent} example #{index}" + annotations = entity_annotations(example, location, context) + for value, entity in annotations: + if entity not in declared_entities: + context.error(f"{location}: undeclared entity '{entity}'") + if entity == "person_name": + counts[split] += 1 + name_values.add(normalize_name(value)) + if intent != "inform_name": + context.error( + f"{location}: person_name may only be annotated in inform_name" + ) + + person_annotations = [ + value for value, entity in annotations if entity == "person_name" + ] + if intent == "inform_name" and len(person_annotations) != 1: + context.error( + f"{location}: expected exactly one person_name annotation, " + f"found {len(person_annotations)}" + ) + + for left, right in combinations(names_by_split, 2): + leaked_names = sorted(names_by_split[left] & names_by_split[right]) + if leaked_names: + context.error( + f"person_name values overlap between {left} and {right}: " + + ", ".join(leaked_names) + ) + + train_names = names_by_split.get("train", set()) + unseen_from_train = { + split: len(names - train_names) + for split, names in names_by_split.items() + if split != "train" + } + return counts, unseen_from_train + + +def maximum_same_intent_similarity( + train: Mapping[str, Sequence[str]], + test: Mapping[str, Sequence[str]], +) -> tuple[float, str | None, int | None]: + """Return the largest train/test similarity after masking entity values.""" + + maximum = 0.0 + maximum_intent: str | None = None + maximum_index: int | None = None + for intent, test_examples in test.items(): + train_templates = [ + normalize_entity_template(example) + for example in train.get(intent, ()) + ] + for index, example in enumerate(test_examples, start=1): + template = normalize_entity_template(example) + score = max( + ( + SequenceMatcher(None, template, candidate).ratio() + for candidate in train_templates + ), + default=0.0, + ) + if score > maximum: + maximum = score + maximum_intent = intent + maximum_index = index + return maximum, maximum_intent, maximum_index + + +def walk_key_values(value: Any, key: str) -> Iterable[Any]: + if isinstance(value, Mapping): + for candidate_key, candidate_value in value.items(): + if candidate_key == key: + yield candidate_value + yield from walk_key_values(candidate_value, key) + elif isinstance(value, list): + for item in value: + yield from walk_key_values(item, key) + + +def collect_string_references(value: Any, key: str) -> set[str]: + return { + reference + for reference in walk_key_values(value, key) + if isinstance(reference, str) + } + + +def action_implementation_details(source: str) -> tuple[set[str], set[str]]: + tree = ast.parse(source, filename="actions/actions.py") + implemented: set[str] = set() + response_references: set[str] = set() + + for class_node in (node for node in tree.body if isinstance(node, ast.ClassDef)): + inherits_action = any( + isinstance(base, ast.Name) and base.id == "Action" + or isinstance(base, ast.Attribute) and base.attr == "Action" + for base in class_node.bases + ) + if not inherits_action: + continue + for function_node in ( + node + for node in class_node.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "name" + ): + for return_node in ast.walk(function_node): + if ( + isinstance(return_node, ast.Return) + and isinstance(return_node.value, ast.Constant) + and isinstance(return_node.value.value, str) + ): + implemented.add(return_node.value.value) + + for call in (node for node in ast.walk(tree) if isinstance(node, ast.Call)): + for keyword in call.keywords: + if keyword.arg not in {"response", "template"}: + continue + if isinstance(keyword.value, ast.Constant) and isinstance( + keyword.value.value, str + ): + response_references.add(keyword.value.value) + + return implemented, response_references + + +def schema_version(document: Any) -> str | None: + if not isinstance(document, Mapping): + return None + value = document.get("version") + return str(value) if value is not None else None + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + + context = ValidationContext() + for relative_path in REQUIRED_FILES: + if not (ROOT / relative_path).is_file(): + context.error(f"missing required file: {relative_path}") + + for relative_path in SCHEMA_31_FILES: + document = context.load_yaml(relative_path) + if document is not None and schema_version(document) != "3.1": + context.error(f"{relative_path}: expected schema version 3.1") + + domain = context.load_yaml("domain.yml") + train_document = context.load_yaml("data/nlu.yml") + dev_document = context.load_yaml("tests/nlu_dev.yml") + test_document = context.load_yaml("tests/nlu_test.yml") + rules = context.load_yaml("data/rules.yml") + stories = context.load_yaml("data/stories.yml") + test_stories = context.load_yaml("tests/test_stories.yml") + compose = context.load_yaml("docker-compose.yml") + + train = nlu_examples(train_document, "data/nlu.yml", context) + dev = nlu_examples(dev_document, "tests/nlu_dev.yml", context) + test = nlu_examples(test_document, "tests/nlu_test.yml", context) + train_intents = set(train) + dev_intents = set(dev) + test_intents = set(test) + + for split, split_intents in (("dev", dev_intents), ("test", test_intents)): + if train_intents != split_intents: + context.error( + f"train/{split} intent sets differ; only in train: " + + ", ".join(sorted(train_intents - split_intents)) + + f"; only in {split}: " + + ", ".join(sorted(split_intents - train_intents)) + ) + if train_intents != EXPECTED_INTENTS: + context.error( + "train intent taxonomy differs from the expected 21 intents; missing: " + + ", ".join(sorted(EXPECTED_INTENTS - train_intents)) + + "; unexpected: " + + ", ".join(sorted(train_intents - EXPECTED_INTENTS)) + ) + + ordinary_intents = EXPECTED_INTENTS - {"inform_name", "out_of_scope"} + expected_train_counts = { + intent: 35 for intent in ordinary_intents + } | {"inform_name": 60, "out_of_scope": 70} + expected_eval_counts = { + intent: 10 for intent in ordinary_intents + } | {"inform_name": 15, "out_of_scope": 30} + + for intent, expected in sorted(expected_train_counts.items()): + actual = len(train.get(intent, ())) + if actual != expected: + context.error( + f"data/nlu.yml:{intent}: expected exactly {expected} train examples, " + f"found {actual}" + ) + for split, dataset in (("dev", dev), ("test", test)): + for intent, expected in sorted(expected_eval_counts.items()): + actual = len(dataset.get(intent, ())) + if actual != expected: + context.error( + f"tests/nlu_{split}.yml:{intent}: expected exactly {expected} " + f"examples, found {actual}" + ) + + train_duplicates = duplicate_examples(train) + dev_duplicates = duplicate_examples(dev) + test_duplicates = duplicate_examples(test) + if train_duplicates: + context.error( + f"data/nlu.yml: found {len(train_duplicates)} normalized duplicate group(s)" + ) + if dev_duplicates: + context.error( + f"tests/nlu_dev.yml: found {len(dev_duplicates)} normalized duplicate group(s)" + ) + if test_duplicates: + context.error( + f"tests/nlu_test.yml: found {len(test_duplicates)} normalized duplicate group(s)" + ) + + train_texts = { + normalize_text(example) + for examples in train.values() + for example in examples + } + dev_texts = { + normalize_text(example) + for examples in dev.values() + for example in examples + } + test_texts = { + normalize_text(example) + for examples in test.values() + for example in examples + } + leakage_by_pair = { + "train/dev": sorted((train_texts & dev_texts) - {""}), + "train/test": sorted((train_texts & test_texts) - {""}), + "dev/test": sorted((dev_texts & test_texts) - {""}), + } + for pair, leakage in leakage_by_pair.items(): + if leakage: + context.error( + f"{pair} exact leakage: found {len(leakage)} normalized example(s)" + ) + + train_templates = { + normalize_entity_template(example) + for examples in train.values() + for example in examples + } + test_templates = [ + normalize_entity_template(example) + for examples in test.values() + for example in examples + ] + repeated_test_templates = len(test_templates) - len(set(test_templates)) + if repeated_test_templates: + context.error( + "tests/nlu_test.yml: entity-masked templates contain " + f"{repeated_test_templates} duplicate(s)" + ) + template_leakage = (train_templates & set(test_templates)) - {""} + if template_leakage: + context.error( + "train/test entity-template leakage: found " + f"{len(template_leakage)} normalized template(s)" + ) + + maximum_similarity, similar_intent, similar_index = ( + maximum_same_intent_similarity(train, test) + ) + if maximum_similarity >= 0.85: + context.error( + "train/test near-copy threshold exceeded: " + f"{similar_intent} example #{similar_index} has similarity " + f"{maximum_similarity:.3f} (limit < 0.850)" + ) + + dev_test_maximum_similarity, dev_test_similar_intent, dev_test_similar_index = ( + maximum_same_intent_similarity(dev, test) + ) + if dev_test_maximum_similarity >= 0.85: + context.error( + "dev/test near-copy threshold exceeded: " + f"{dev_test_similar_intent} example #{dev_test_similar_index} has " + f"similarity {dev_test_maximum_similarity:.3f} (limit < 0.850)" + ) + + domain_mapping = domain if isinstance(domain, Mapping) else {} + domain_intents = string_set(domain_mapping.get("intents"), "domain.yml:intents", context) + expected_domain_intents = train_intents | SYSTEM_INTENTS + if domain_intents != expected_domain_intents: + context.error( + "domain intents must equal train intents plus nlu_fallback; missing: " + + ", ".join(sorted(expected_domain_intents - domain_intents)) + + "; unexpected: " + + ", ".join(sorted(domain_intents - expected_domain_intents)) + ) + + declared_entities = string_set( + domain_mapping.get("entities"), "domain.yml:entities", context + ) + if "person_name" not in declared_entities: + context.error("domain.yml: entity person_name is not declared") + + slots = domain_mapping.get("slots") + user_name = slots.get("user_name") if isinstance(slots, Mapping) else None + mappings = user_name.get("mappings") if isinstance(user_name, Mapping) else None + if not ( + isinstance(mappings, list) + and any( + isinstance(mapping, Mapping) + and mapping.get("type") == "from_entity" + and mapping.get("entity") == "person_name" + for mapping in mappings + ) + ): + context.error("domain.yml: slot user_name is not mapped from person_name") + + entity_counts, unseen_names = validate_entities( + {"train": train, "dev": dev, "test": test}, + declared_entities, + context, + ) + + responses_value = domain_mapping.get("responses") + responses = set(responses_value) if isinstance(responses_value, Mapping) else set() + if not isinstance(responses_value, Mapping): + context.error("domain.yml: responses must be a mapping") + else: + for response_name, variants in responses_value.items(): + if not isinstance(variants, list) or not variants: + context.error(f"domain.yml:{response_name}: response variants are empty") + + declared_actions = string_set( + domain_mapping.get("actions"), "domain.yml:actions", context + ) + + dialogue_documents = { + "data/rules.yml": rules, + "data/stories.yml": stories, + "tests/test_stories.yml": test_stories, + } + intent_references: set[str] = set() + action_references: set[str] = set() + response_references: set[str] = set() + for relative_path, document in dialogue_documents.items(): + if not isinstance(document, Mapping): + context.error(f"{relative_path}: top-level YAML value must be a mapping") + continue + file_intents = collect_string_references(document, "intent") + file_actions = collect_string_references(document, "action") + file_responses = collect_string_references(document, "response") + intent_references.update(file_intents) + action_references.update(file_actions) + response_references.update(file_responses) + + unknown_intents = file_intents - domain_intents + if unknown_intents: + context.error( + f"{relative_path}: unknown intent reference(s): " + + ", ".join(sorted(unknown_intents)) + ) + + response_references.update( + action for action in action_references if action.startswith("utter_") + ) + missing_responses = response_references - responses + if missing_responses: + context.error( + "dialogue/action response reference(s) missing from domain: " + + ", ".join(sorted(missing_responses)) + ) + + custom_action_references = { + action + for action in action_references + if not action.startswith("utter_") and action not in BUILT_IN_ACTIONS + } + undeclared_action_references = custom_action_references - declared_actions + if undeclared_action_references: + context.error( + "dialogue custom action reference(s) missing from domain: " + + ", ".join(sorted(undeclared_action_references)) + ) + + action_source_path = ROOT / "actions/actions.py" + implemented_actions: set[str] = set() + source_response_references: set[str] = set() + if action_source_path.is_file(): + try: + source = action_source_path.read_text(encoding="utf-8") + implemented_actions, source_response_references = action_implementation_details( + source + ) + except (OSError, UnicodeError, SyntaxError) as exc: + context.error(f"actions/actions.py: cannot inspect custom actions: {exc}") + + missing_implementations = declared_actions - implemented_actions + undeclared_implementations = implemented_actions - declared_actions + if missing_implementations: + context.error( + "declared custom action(s) without implementation: " + + ", ".join(sorted(missing_implementations)) + ) + if undeclared_implementations: + context.error( + "implemented custom action(s) missing from domain: " + + ", ".join(sorted(undeclared_implementations)) + ) + unused_actions = declared_actions - custom_action_references + if unused_actions: + context.error( + "declared custom action(s) are not referenced by dialogue data: " + + ", ".join(sorted(unused_actions)) + ) + missing_source_responses = source_response_references - responses + if missing_source_responses: + context.error( + "actions/actions.py references unknown response(s): " + + ", ".join(sorted(missing_source_responses)) + ) + + services = compose.get("services") if isinstance(compose, Mapping) else None + if not isinstance(services, Mapping): + context.error("docker-compose.yml: top-level services mapping is missing") + else: + rasa_service = services.get("rasa") + action_service = services.get("action_server") + rasa_image = rasa_service.get("image") if isinstance(rasa_service, Mapping) else None + action_image = ( + action_service.get("image") if isinstance(action_service, Mapping) else None + ) + if rasa_image != "rasa/rasa:3.6.21": + context.error( + "docker-compose.yml: rasa image must be pinned to rasa/rasa:3.6.21" + ) + if action_image != "rasa/rasa-sdk:3.6.2": + context.error( + "docker-compose.yml: action server image must be pinned to " + "rasa/rasa-sdk:3.6.2" + ) + + metrics = { + "train_intents": len(train_intents), + "dev_intents": len(dev_intents), + "test_intents": len(test_intents), + "train_examples": sum(len(examples) for examples in train.values()), + "dev_examples": sum(len(examples) for examples in dev.values()), + "test_examples": sum(len(examples) for examples in test.values()), + "train_person_name_annotations": entity_counts.get("train", 0), + "dev_person_name_annotations": entity_counts.get("dev", 0), + "test_person_name_annotations": entity_counts.get("test", 0), + "unseen_dev_person_names": unseen_names.get("dev", 0), + "unseen_test_person_names": unseen_names.get("test", 0), + "normalized_train_duplicates": len(train_duplicates), + "normalized_dev_duplicates": len(dev_duplicates), + "normalized_test_duplicates": len(test_duplicates), + "train_dev_exact_overlap": len(leakage_by_pair["train/dev"]), + "train_test_exact_overlap": len(leakage_by_pair["train/test"]), + "dev_test_exact_overlap": len(leakage_by_pair["dev/test"]), + "train_test_template_overlap": len(template_leakage), + "train_test_max_same_intent_similarity": f"{maximum_similarity:.3f}", + "dev_test_max_same_intent_similarity": ( + f"{dev_test_maximum_similarity:.3f}" + ), + "domain_responses": len(responses), + "declared_custom_actions": len(declared_actions), + "implemented_custom_actions": len(implemented_actions), + "rules": len(rules.get("rules", [])) if isinstance(rules, Mapping) else 0, + "stories": len(stories.get("stories", [])) if isinstance(stories, Mapping) else 0, + "test_stories": ( + len(test_stories.get("stories", [])) + if isinstance(test_stories, Mapping) + else 0 + ), + } + + print("Static project metrics:") + for key, value in metrics.items(): + print(f" {key}: {value}") + print("Per-intent train/dev/test examples:") + for intent in sorted(train_intents | dev_intents | test_intents): + print( + f" {intent}: {len(train.get(intent, ()))} / " + f"{len(dev.get(intent, ()))} / {len(test.get(intent, ()))}" + ) + + if context.errors: + print("\nSTATIC VALIDATION: FAILED") + for error in context.errors: + print(f" - {error}") + return 1 + + print("\nSTATIC VALIDATION: OK") + print( + "Static consistency passed. Run Rasa validation and trained-model tests " + "for runtime and quality metrics." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_actions.py b/tests/test_actions.py new file mode 100644 index 0000000..823f6f3 --- /dev/null +++ b/tests/test_actions.py @@ -0,0 +1,140 @@ +from datetime import datetime, timedelta +from typing import Any, Optional + +import pytest +from rasa_sdk import Tracker +from rasa_sdk.executor import CollectingDispatcher + +from actions import actions as actions_module +from actions.actions import ( + ActionRememberName, + ActionShowCapabilities, + ActionShowDate, + ActionShowTime, +) + + +def make_tracker(user_name: Optional[str] = None) -> Tracker: + """Build the smallest real Rasa tracker needed by the custom actions.""" + return Tracker( + sender_id="unit-test-user", + slots={"user_name": user_name}, + latest_message={}, + events=[], + paused=False, + followup_action=None, + active_loop={}, + latest_action_name=None, + ) + + +def run_action(action: Any, user_name: Optional[str] = None) -> tuple[list, str]: + dispatcher = CollectingDispatcher() + + events = action.run(dispatcher, make_tracker(user_name), {}) + + assert len(dispatcher.messages) == 1 + text = dispatcher.messages[0].get("text") + assert isinstance(text, str) and text + assert any("а" <= character.lower() <= "я" or character.lower() == "ё" for character in text) + assert "Ð" not in text and "Ñ" not in text and "�" not in text + return events, text + + +@pytest.mark.parametrize( + ("action", "expected_name"), + [ + (ActionRememberName(), "action_remember_name"), + (ActionShowCapabilities(), "action_show_capabilities"), + (ActionShowTime(), "action_show_time"), + (ActionShowDate(), "action_show_date"), + ], +) +def test_action_names(action: Any, expected_name: str) -> None: + assert action.name() == expected_name + + +def test_remember_name_addresses_user_and_returns_no_events() -> None: + events, text = run_action(ActionRememberName(), "Анна") + + assert events == [] + assert text == ( + "Приятно познакомиться, Анна! " + "Я запомню ваше имя в рамках текущего диалога." + ) + + +@pytest.mark.parametrize("missing_name", [None, ""]) +def test_remember_name_asks_to_repeat_when_slot_is_empty( + missing_name: Optional[str], +) -> None: + events, text = run_action(ActionRememberName(), missing_name) + + assert events == [] + assert text == ( + "Я не смог выделить имя. " + "Напишите, например: «Меня зовут Анна»." + ) + + +@pytest.mark.parametrize( + ("user_name", "expected_prefix"), + [(None, "Я могу"), ("Илья", "Илья, я могу")], +) +def test_show_capabilities_lists_supported_topics( + user_name: Optional[str], expected_prefix: str +) -> None: + events, text = run_action(ActionShowCapabilities(), user_name) + + assert events == [] + assert text.startswith(expected_prefix) + for topic in ( + "Rasa", + "NLU", + "Docker", + "обучение", + "запуск", + "структуру файлов", + "дату и время", + ): + assert topic in text + + +@pytest.fixture +def fixed_datetime(monkeypatch: pytest.MonkeyPatch) -> list: + requested_timezones = [] + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + requested_timezones.append(tz) + return cls(2026, 7, 22, 9, 5, 0, tzinfo=tz) + + monkeypatch.setattr(actions_module, "datetime", FixedDateTime) + return requested_timezones + + +def assert_tashkent_timezone_was_requested(requested_timezones: list) -> None: + assert len(requested_timezones) == 1 + assert requested_timezones[0] is not None + assert requested_timezones[0].utcoffset(None) == timedelta(hours=5) + + +def test_show_time_uses_tashkent_timezone_and_zero_padded_format( + fixed_datetime: list, +) -> None: + events, text = run_action(ActionShowTime()) + + assert events == [] + assert text == "Сейчас 09:05 по времени Ташкента (UTC+5)." + assert_tashkent_timezone_was_requested(fixed_datetime) + + +def test_show_date_uses_russian_month_and_tashkent_timezone( + fixed_datetime: list, +) -> None: + events, text = run_action(ActionShowDate()) + + assert events == [] + assert text == "Сегодня 22 июля 2026 года по времени Ташкента." + assert_tashkent_timezone_was_requested(fixed_datetime) diff --git a/tests/test_stories.yml b/tests/test_stories.yml new file mode 100644 index 0000000..fd3c946 --- /dev/null +++ b/tests/test_stories.yml @@ -0,0 +1,83 @@ +version: "3.1" + +# Эти сценарии проверяют только предсказание действий Rasa Core. +# Качество распознавания текста проверяется отдельно на tests/nlu_test.yml. +stories: +- story: Core — знакомство и обзор проекта + steps: + - intent: greet + - action: utter_greet + - intent: inform_name + entities: + - person_name: Анна + - action: action_remember_name + - intent: help + - action: action_show_capabilities + - intent: ask_project + - action: utter_project + +- story: Core — имя и автор + steps: + - intent: ask_name + - action: utter_name + - intent: ask_creator + - action: utter_creator + - intent: thanks + - action: utter_you_are_welcome + +- story: Core — Rasa NLU и обучение + steps: + - intent: ask_rasa + - action: utter_rasa + - intent: ask_nlu + - action: utter_nlu + - intent: ask_training + - action: utter_training + +- story: Core — структура Docker и запуск + steps: + - intent: ask_project_structure + - action: utter_project_structure + - intent: ask_docker + - action: utter_docker + - intent: ask_run + - action: utter_run + +- story: Core — дата и время + steps: + - intent: ask_date + - action: action_show_date + - intent: ask_time + - action: action_show_time + +- story: Core — короткий разговор + steps: + - intent: smalltalk_howareyou + - action: utter_howareyou + - intent: smalltalk_joke + - action: utter_joke + - intent: goodbye + - action: utter_goodbye + +- story: Core — помощь после плохого ответа + steps: + - intent: feedback_bad + - action: utter_feedback_bad + - intent: help + - action: action_show_capabilities + +- story: Core — известная неподдерживаемая тема + steps: + - intent: out_of_scope + - action: utter_out_of_scope + +- story: Core — низкая уверенность NLU + steps: + - intent: nlu_fallback + - action: utter_default + +- story: Core — перезапуск разговора + steps: + - intent: ask_restart + - action: utter_restart + - action: action_restart