Skip to content

Repository files navigation

Ассистент по промышленной безопасности

Локальный AI-агент, который читает технические паспорта плавильных печей и нормативную базу, сопоставляет характеристики оборудования с порогами закона и обосновывает класс опасности ОПО — со ссылкой на статью, таблицу и страницу источника.

100% on-premise · LangGraph ReAct · Qwen3-8B (llama.cpp) · Qdrant hybrid RRF · FastAPI SSE · React + TypeScript · Docker Compose


Задача

Чтобы зарегистрировать опасный производственный объект, специалисту нужно свести два разных мира документов: технический паспорт конкретной печи (ёмкость ванны, вид расплава, температура) и нормативную базу — 116-ФЗ с приложениями и Постановление Правительства РФ №1363 с типовыми наименованиями ОПО. Работа механическая, но цена ошибки — неверный класс опасности в госреестре.

Сложность в источниках:

  • Паспорта существуют только как сканы — перекошенные страницы, колонтитулы, таблицы характеристик.
  • Закон — иерархия статей и пунктов, где ответ часто лежит в приложении, а не в теле статьи.
  • Обычный векторный поиск путает ОПП-1 и ОПП-1,2-07 и с равной уверенностью цитирует оглавление.

Отдельное требование заказчика: контур закрытый. Ни модель, ни эмбеддинги, ни векторная база не могут покидать сервер предприятия.


Что система умеет

Агент решает задачу не одним поиском, а планом: сначала достаёт факты об оборудовании, затем ищет норму, затем сопоставляет. Четыре инструмента — четыре разных источника истины.

Инструмент Что делает
industrial_machines_search Характеристики, комплектность, конструкция, регламент обслуживания четырёх печей ОПП. Модель печи из запроса превращается в жёсткий фильтр по метаданным.
law_search Классификация ОПО и классы опасности I–IV, обязанности эксплуатирующей организации, декларирование, лицензирование, страхование, производственный контроль.
naming_search Подбор типового наименования (именного кода) по ПП РФ №1363: раздел промышленности, опасные процессы, границы объекта, правила идентификации.
get_document Аварийный выход из RAG: если после двух переформулировок фрагмент не нашёлся, агент забирает документ целиком и читает его сам.

Поверх инструментов:

  • Обоснование, а не ответ. Итог оформляется по фиксированному шаблону: признак ОПО, класс опасности и сравнение «параметр оборудования против порога из закона».
  • Проверка фактов после ответа. Второй проход LLM сверяет каждое число и класс из ответа с текстом, реально вернувшимся из поиска.
  • Видимый ход рассуждений. Reasoning-поток модели, вызовы инструментов и их результаты стримятся в интерфейс отдельными каналами — пользователь видит, откуда взялся вывод.
  • Ссылка на источник обязательна. Каждое утверждение сопровождается статьёй, приложением, таблицей или страницей паспорта.

Архитектура

Всё, что нужно для ответа, живёт внутри одной сети Docker: две инференс-ноды llama.cpp (генерация и эмбеддинги), векторная база, файловый сервис документов, агент и фронтенд за nginx. Единственный порт наружу — 80.

flowchart LR
  U["Инженер<br/>браузер"] --> NG["nginx :80<br/>SSE без буферизации"]
  NG --> FE["React SPA<br/>Vite build"]
  NG -->|"POST /agent/chat"| AG["FastAPI + LangGraph<br/>ReAct-агент :8001"]

  AG -->|"чат, reasoning,<br/>fact-check"| LC["llama-chat :8080<br/>Qwen3-8B Q4_K_M"]
  AG -->|"dense-вектор запроса"| LE["llama-embed :8081<br/>BGE-M3 Q4_K_M"]
  AG -->|"hybrid query<br/>dense + sparse"| QD[("Qdrant :6333<br/>documents / law / naming")]
  AG -->|"полный текст .md"| DS["docs-api :8000"]
Loading

GPU-слои llama.cpp пробрасываются override-файлом, поэтому одна и та же конфигурация поднимается на CUDA, Apple Metal и чистом CPU.


Поток запроса

Агент работает в цикле ReAct: думает, вызывает инструмент, смотрит результат, при необходимости переформулирует запрос. Всё это время фронтенд получает события по SSE — ожидание не выглядит как зависший спиннер.

sequenceDiagram
  autonumber
  participant U as Инженер
  participant A as Агент · LangGraph
  participant L as Qwen3-8B
  participant Q as Qdrant + BGE-M3

  U->>A: "Какой класс опасности у печи ОПП-3,0?"
  A->>L: планирование, reasoning-бюджет 2048 токенов
  L-->>A: вызвать industrial_machines_search
  A->>Q: hybrid-поиск + фильтр device_model = ОПП-3,0
  Q-->>A: фрагменты паспорта, section_path, page_num
  A-->>U: SSE tool-call / tool-result
  L-->>A: вызвать law_search
  A->>Q: "класс опасности объекта с расплавами металлов"
  Q-->>A: норма 116-ФЗ со ссылкой на статью
  L-->>A: стрим ответа по шаблону
  A-->>U: SSE thinking / tokens (потоки разделены)
  A->>L: проверка фактов: ответ против контекста поиска
  L-->>A: список подтверждённых утверждений
  A-->>U: SSE verification
Loading

Всё это — один HTTP-запрос с одним SSE-потоком. Верификация запускается только если инструменты действительно что-то вернули: проверять нечего, если модель не опиралась на документы.


Пайплайн данных: скан → markdown → чанки → векторы

Исходники — PDF-сканы паспортов и текст нормативных актов. Перед распознаванием страницы выпрямляются: угол наклона определяется преобразованием Хафа по длинным горизонтальным линиям, затем страница поворачивается и обрезается от колонтитулов. Кривой скан — главный источник мусорных чанков.

flowchart TB
  subgraph P1["1 · Подготовка"]
    A1["PDF-скан паспорта"] --> A2["PyMuPDF → растр страниц"]
    A2 --> A3["OpenCV: детекция наклона<br/>по Хафу, deskew, crop"]
    A3 --> A4["Docling: layout + таблицы<br/>→ структурный Markdown"]
    B1["116-ФЗ, ПП РФ №1363"] --> A4
  end

  subgraph P2["2 · Чанкинг по структуре"]
    A4 --> C1{"тип документа"}
    C1 -->|закон| C2["разбор по статьям,<br/>главам, приложениям,<br/>затем по пунктам"]
    C1 -->|паспорт| C3["разбор по заголовкам<br/>разделов, крупные —<br/>по абзацам"]
    C1 -->|ПП 1363| C4["строка таблицы = чанк:<br/>наименование, процессы,<br/>границы, правила"]
  end

  subgraph P3["3 · Обогащение и загрузка"]
    C2 --> D1["метаданные: device_model,<br/>section_path, page_num,<br/>chunk_type, parent_content"]
    C3 --> D1
    C4 --> D1
    D1 --> D2["dense: BGE-M3"]
    D1 --> D3["sparse: термовые веса"]
    D2 --> D4[("Qdrant: именованные<br/>векторы в одной точке")]
    D3 --> D4
  end
Loading

Чанкинг не универсальный, а по типу документа: у закона единица смысла — пункт статьи, у паспорта — раздел, у ПП №1363 — строка таблицы наименований. Одна общая стратегия резала бы нормы посередине.

Почему поиск гибридный

Плотные векторы отлично ловят перефразировку («ёмкость ванны» ↔ «объём расплава»), но одинаково близко располагают ОПП-1 и ОПП-1,2-07. Разреженные — наоборот, точны в кодах и цифрах, но беспомощны к синонимам. В домене, где модель печи и пороговое значение решают всё, нужны обе ветки.

flowchart LR
  Q["Запрос:<br/>«ёмкость ванны ОПП-3,0»"] --> RX["regex: марка печи<br/>из текста запроса"]
  RX --> F["Filter: must device_model,<br/>must_not оглавление<br/>и служебные чанки"]
  Q --> DV["BGE-M3 → dense"]
  Q --> SV["sparse-эмбеддер → sparse"]
  DV --> P1["Prefetch dense<br/>limit 2k"]
  SV --> P2["Prefetch sparse<br/>limit 2k"]
  F -.->|"применяется к обеим ветвям"| P1
  F -.-> P2
  P1 --> RRF{{"Reciprocal Rank Fusion"}}
  P2 --> RRF
  RRF --> TOP["top-10 фрагментов<br/>+ section_path + page_num"]
Loading

Ранги двух ветвей сливаются по RRF на стороне Qdrant — одним запросом, без второго сетевого хопа и без ручной нормализации несопоставимых скоров.


Инженерные решения

Модель печи как жёсткий фильтр, а не как подсказка. Ранняя версия отвечала характеристиками ОПП-1,2-07 на вопрос про ОПП-1: строки паспортов семантически почти идентичны. Решение — вытаскивать модель регулярным выражением из запроса и превращать в must-условие Qdrant, а оглавления и служебные чанки таблиц отсекать через must_not. → Смешение моделей в выдаче устранено; оглавление больше не попадает в контекст.

Разделение reasoning-потока и ответа на лету. Qwen3 отдаёт размышления в тегах <think>, и при стриминге тег приходит разорванным между чанками. Написан инкрементальный фильтр с буфером: он придерживает хвост длиной в тег, поэтому рассуждения и финальный текст уходят в разные SSE-события и никогда не перемешиваются. → Пользователь видит ход мысли отдельным блоком, в историю диалога он не попадает.

Проверка фактов вторым проходом. После ответа собирается контекст из всех результатов инструментов, и отдельный вызов LLM с ролью независимого проверщика ищет в ответе числа, классы опасности и пороги, проверяя наличие каждого в контексте. Вызов обёрнут в таймаут: сбой проверки не должен ронять ответ. → Итог помечается «✓ Все утверждения подтверждены» либо «⚠ Найдены расхождения».

Одна конфигурация под три вида железа. Установщик определяет окружение сам: NVIDIA — CUDA-образы llama.cpp с GPU-override, macOS с llama.cpp — нативные серверы с Metal и проксированием через host.docker.internal, иначе — CPU. База знаний приезжает заказчику снапшотами Qdrant и восстанавливается тем же скриптом. → Развёртывание на закрытом контуре — один ./install.sh.

Промпт как регламент, а не как пожелание. Системный промпт описывает два рабочих сценария по шагам, задаёт шаблон ответа и явно запрещает обобщения: цитировать только полученное из инструментов, до трёх переформулировок запроса перед признанием «данных нет», всегда указывать источник. Описания инструментов включают примеры формулировок и стратегию отступления. → Модель на 8B параметров держит дисциплину вывода без дообучения.


Примеры использования

Сценарий 1 — классификация ОПО

Инженер: Печь ОПП-3,0 — это опасный производственный объект? Если да, какой класс опасности?

Вызовы агента:

→ industrial_machines_search — «ёмкость ванны, вид расплава ОПП-3,0»
→ law_search — «класс опасности объекта с расплавами металлов»

Ответ по шаблону:

  • Оборудование: Печь плавильная ОПП-3,0
  • Является ОПО: да
  • Почему ОПО: получение и использование расплавов чёрных металлов — признак из 116-ФЗ
  • Класс опасности: по порогу массы расплава из приложения к закону
  • Обоснование: ёмкость ванны из паспорта сопоставлена с пороговым значением нормы, обе цифры со ссылками

✓ Все утверждения подтверждены — ёмкость ванны и порог найдены в контексте

Сценарий 2 — типовое наименование по ПП РФ №1363

Инженер: Под каким типовым наименованием регистрировать участок с печью ОПП-1?

→ industrial_machines_search — назначение и вид процесса
→ naming_search — «плавка чёрных металлов в печи»   (описание процесса, не марка печи)

Агент возвращает типовое наименование из ПП РФ №1363 с указанием раздела классификации, перечня опасных процессов и границ объекта. Если кандидатов несколько — объясняет, почему выбран именно этот, по основной функции оборудования.

Сценарий 3 — справка по документации

Инженер: Какая периодичность капитального ремонта и габаритные размеры у ОПП-1,2-07?

Агент отвечает данными из соответствующих разделов паспорта с номерами страниц. Если фрагмент не находится после двух переформулировок — забирает документ целиком через get_document и отвечает по полному тексту.


Стек и мотивация выбора

Компонент Роль Почему так
LangGraph ReAct-агент, цикл «мысль → инструмент → наблюдение» Нужны были повторные попытки поиска и потоковые события по каждому шагу, а не одиночный retrieve-then-read
Qwen3-8B Q4_K_M Генерация, планирование, fact-check Управляемый reasoning-бюджет, хороший русский и tool-calling при ~5 ГБ квантованных весов
llama.cpp server Инференс LLM и эмбеддингов OpenAI-совместимый API локально; один образ покрывает CUDA, Metal и CPU
BGE-M3 Плотные эмбеддинги Сильная многоязычная модель, устойчива к русской юридической лексике
Qdrant Векторное хранилище, гибридный поиск Именованные dense/sparse-векторы, серверный RRF, фильтры по метаданным, снапшоты для поставки
Docling PDF → структурный Markdown Восстанавливает разметку и таблицы; работает офлайн на локальных артефактах моделей
OpenCV · PyMuPDF Deskew, обрезка, растеризация Кривой скан ломает распознавание таблиц раньше, чем до них дойдёт OCR
FastAPI · SSE Стриминговый API агента Асинхронность под долгие вызовы инструментов; SSE проще WebSocket для однонаправленного потока
React 18 · TypeScript · Vite Интерфейс чата Отдельные блоки под reasoning, шаги инструментов и результат верификации
Docker Compose · nginx Оркестрация и точка входа Отключённая буферизация прокси — обязательное условие живого SSE

Структура репозитория

.
├── agent/                   # FastAPI + LangGraph: граф, инструменты, SSE-сервер, память
│   ├── graph.py             # ReAct-агент над локальной LLM
│   ├── tools.py             # 4 инструмента, гибридный поиск в Qdrant
│   ├── server.py            # SSE-стрим, think-фильтр, проверка фактов
│   └── config.py            # системный промпт-регламент
├── frontend/                # React + TypeScript + Vite
├── ingestion/               # пайплайн подготовки данных (запуск на хосте)
├── models/                  # GGUF-веса (qwen3-8b, bge-m3)
├── qdrant-snapshots/        # база знаний для восстановления
├── files/                   # исходные и конвертированные документы
├── docs_server.py           # файловый API полных текстов
├── docker-compose.yml       # 7 сервисов
├── docker-compose.gpu.yml   # override проброса NVIDIA GPU
├── nginx.conf               # точка входа, SSE без буферизации
└── install.sh               # авто-детект железа, загрузка моделей, восстановление базы

Эксплуатация

Требования к железу

Компонент Минимум Рекомендуется
CPU 8 ядер 8+ ядер
RAM 16 GB 32 GB
GPU NVIDIA 8 GB VRAM NVIDIA 12+ GB VRAM
Диск 20 GB свободно 50 GB SSD
ОС Ubuntu 22.04 / 24.04 Ubuntu 24.04

Установка

Шаг 1. Установить Docker

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
newgrp docker

Шаг 2. Установить NVIDIA Container Toolkit

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt update && sudo apt install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

Проверка что GPU виден:

nvidia-smi

Шаг 3. Запустить установку

chmod +x install.sh
./install.sh

Скрипт автоматически:

  • определит режим работы (CUDA / Metal / CPU);
  • скачает GGUF-веса моделей;
  • запустит сервисы и дождётся готовности Qdrant;
  • восстановит базу знаний из снапшотов;
  • поднимет агент, фронтенд и nginx после загрузки модели в память.

Шаг 4. Открыть в браузере

http://localhost

Управление сервисами

docker compose up -d              # запустить
docker compose down               # остановить
docker compose restart agent      # перезапустить один сервис
docker compose ps                 # статус
docker compose logs -f agent      # логи сервиса

Диагностика

Сервис не запускается

docker compose logs <имя_сервиса>

GPU не определяется

nvidia-smi
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi

База знаний пустая

curl http://localhost:6333/collections

Если коллекции отсутствуют — повторить восстановление снапшотов:

for snapshot in qdrant-snapshots/*.snapshot; do
  filename=$(basename "$snapshot")
  collection=$(echo "$filename" | sed 's/-[0-9].*//')
  curl -X POST \
    "http://localhost:6333/collections/${collection}/snapshots/upload?priority=snapshot" \
    -H "Content-Type: multipart/form-data" \
    -F "snapshot=@${snapshot}"
done

Модель отвечает медленно — проверить, что она работает на GPU, а не CPU:

docker compose logs llama-chat | grep "ggml_cuda"

Если строки с ggml_cuda есть — GPU используется.

Сервисы и порты

Сервис Порт Описание
nginx 80 Точка входа, веб-интерфейс
llama-chat 8080 LLM (qwen3-8b)
llama-embed 8081 Эмбеддинги (bge-m3)
agent 8001 FastAPI агент
qdrant 6333 Векторная база данных
docs-api 8000 API документов

About

RAG-система для работы с Техническими паспортами

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages