# Архитектура DrHider ## Общая схема ``` Пользователь (браузер) │ ▼ https://drhider.pythonk8s.dev.nubes.ru/ │ ▼ Штурвал (Managed Flask на pythonk8s) │ ▼ site/app.py ──→ Flask (create_app) │ ├── GET / → main_bp → templates/index.html ├── GET /health → health_bp → {"ok": true} └── POST /api/upload (→ /api/process_stream/) → api_bp → drhider.obfuscate_files() │ ▼ TwoPassObfuscator │ ┌────────────┼────────────┐ │ │ │ extractor scanner replacer (текст) (regex+LLM) (замена) │ │ │ └────────────┼────────────┘ │ builder (ZIP+CSV) │ ▼ application/zip ``` --- ## Поддерживаемые форматы файлов | Формат | Обработка | Детали | |--------|-----------|--------| | **`.docx`** | → Markdown | Заголовки (H1–H3), **жирный**, *курсив*, таблицы (`python-docx`) | | **`.pdf`** | → Markdown | Текст + таблицы (`pdfplumber`), без форматирования | | **`.txt`** и прочие текстовые | → как есть | Декодируется UTF-8, ошибки заменяются `�` | | **`.zip`** | → распаковка | Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB | | **`.doc`** (бинарный) | → Markdown | Через LibreOffice headless: `.doc` → `.docx` → штатный `docx_to_markdown` | Ограничение на размер загрузки: **200 MB** (`MAX_CONTENT_LENGTH`). --- ## Двухпроходная обфускация ### Проход 1: сбор сущностей ``` Файлы (.docx, .pdf, .doc, .txt, .zip) │ ▼ extractor.expand_zips() ← распаковать ZIP │ ▼ extractor.extract_text() ← извлечь текст из каждого файла │ (.docx → docx_to_markdown, │ .pdf → pdf_to_markdown, │ .doc → doc_to_markdown, │ .txt → как есть) │ ├──→ scanner.scan_regex() ← regex: телефоны, email, ИНН, ОГРН, КПП, │ БИК, счета, паспорта, компании, ФИО │ └──→ scanner.scan_llm_ner() ← LLM: имена, адреса, паспорта (только если llm_client передан) │ ▼ mapping = {оригинал → токен} ← глобальный словарь замен (гибридные токены) ``` ### Проход 2: замена ``` mapping + sorted_keys (по убыванию длины) │ ▼ Для каждого файла: │ ├── .docx → replacer.replace_in_docx() ← склеить runs → заменить → сохранить └── остальные → replacer.apply_replacements() ← замена в тексте (Markdown/plain) │ ▼ builder.build_mapping_csv() ← mapping.csv builder.build_zip() ← ZIP со всеми файлами + mapping.csv ``` --- ## Модули ### `drhider/config.py` Константы уровня всего пакета. НЕ содержит логики — только данные. - `ENTITY_PATTERNS` — 10 regex-паттернов - `COMPANY_PATTERN`, `PERSON_PATTERN` — скомпилированные regex - Словари: `RU_SURNAMES`, `RU_NAMES`, `RU_PATRONYMICS`, `RU_CITIES`, `RU_STREETS`, `FAKE_DOMAINS` ### `drhider/llm_client.py` HTTP-клиент к OpenAI-совместимому API. Читает переменные окружения. Интерфейс: `.complete(prompt: str) -> str`. ### `drhider/extractor.py` Функции (не класс): - `docx_to_markdown(content)` → str (Markdown) - `pdf_to_markdown(content)` → str (Markdown) - `doc_to_markdown(content)` → str (через LibreOffice headless) - `extract_text(fname, content, ctype)` → str - `expand_zips(files)` → распакованный список ### `drhider/scanner.py` Функции (не класс), мутируют переданные `mapping` и `counters`: - `TYPE_POOLS`, `_next_token(entity_type, counters)` — генерация гибридных токенов (шаблон + номер) - `scan_regex(text, mapping, counters)` — regex-поиск - `split_into_chunks(text, size, overlap)` — разбиение текста на чанки для LLM - `normalize_entity(s)` — нормализация сущности перед дедупом - `scan_llm_ner(all_texts, mapping, llm_client)` — LLM NER Фиктивные значения — гибридные токены (`Иванов_0001`, `ООО_Технология_0034`), а не отдельные функции-генераторы на каждый тип. Отдельного пакета `generators/` нет. ### `drhider/replacer.py` Функции: - `apply_replacements(text, mapping, sorted_keys)` → строка с заменами - `replace_in_docx(doc, mapping, sorted_keys)` → bytes (изменённый DOCX) ### `drhider/builder.py` Две функции сборки результата: - `build_zip(files, mapping_csv)` → bytes (ZIP-архив) - `build_mapping_csv(mapping)` → str (CSV) ### `drhider/obfuscator.py` `TwoPassObfuscator` — оркестратор. Единственный класс, который знает о顺序е вызовов. Метод `.obfuscate(files)` вызывает модули в правильном порядке. `obfuscate_files()` — удобная функция-обёртка. ### `site/app.py` Точка входа для Штурвала. `create_app()` создаёт Flask, регистрирует blueprint'ы. `app = create_app()` — глобальный экземпляр. ### `site/routes/` Blueprint'ы — каждый в своём файле: - `main_bp` — только `GET /` - `health_bp` — только `GET /health` - `api_bp` — `POST /api/upload`, `GET /api/process_stream/` (SSE), `POST /api/process/`, `GET /api/download/`, `GET /api/csv/` `__init__.py` содержит `register_routes(app)` — единая точка регистрации. --- ## Поток данных ``` 1. POST /api/upload (multipart/form-data) │ request.files.getlist("files") │ → session.add_file(...) для каждого файла ▼ JSON {ok, session: , count} 2. GET /api/process_stream/ (SSE) │ ▼ api_bp.process_stream(sid) │ worker-поток: │ → [(filename, bytes, ""), ...] из сессии ▼ obfuscate_files(files, llm_client=LLMClient(), progress_cb=...) │ Scanner → Extractor → LLM NER → Obfuscator → Replacer → Builder ▼ bytes (ZIP-архив с обезличенными документами + mapping.csv) ``` --- ## Требования к Flask-приложению для Штурвала ### 1. `/health` — liveness probe ```python @health_bp.route("/health") def health(): return jsonify({"ok": True, "version": "0.0.26"}) ``` **Обязательно.** Штурвал дёргает `/health` для readiness/liveness checks. Без этого под не поднимется. Ответ должен быть `200 OK`. ### 2. `no_cache` на все ответы ```python @app.after_request def no_cache(response): response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" return response ``` **Критично для SPA-подобного UI.** Без этого nginx/браузер кэшируют страницу → F5 показывает старую версию, кнопки не работают, версия не обновляется. ### 3. `X-Accel-Buffering: no` для SSE ```python return Response( stream_with_context(generate()), content_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"} ) ``` **Критично для SSE-прогресса.** Без этого nginx буферизует поток → прогресс не отображается в реальном времени, все события приходят одним блоком в конце. ### 4. `MAX_CONTENT_LENGTH` ```python app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024 # 200 MB ``` Без этого Flask использует дефолтный лимит (~1MB) и отвергает docx/pdf. ### 5. `GeneratorExit` в SSE-генераторе ```python try: yield f"event: start\n..." except GeneratorExit: return # клиент отключился — не продолжаем обработку ``` **Критично.** При закрытии вкладки браузер рвёт SSE-соединение. Без `GeneratorExit` генератор продолжает слать запросы в LLM, тратя токены впустую. ### 6. `stream_with_context` ```python from flask import stream_with_context return Response(stream_with_context(generate()), ...) ``` **Обязательно для SSE.** Без этого Flask держит приложение заблокированным на время SSE-соединения, другие запросы не обрабатываются. ### 7. `sys.path` для импорта пакета drhider ```python _sys_path_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) ``` Штурвал запускает из `site/`, пакет `drhider/` на уровень выше. Без этого импорт не работает. ### 8. In-memory session storage Сессии хранятся в `site/session.py` как `Dict[str, dict]`. **Не персистентны** — теряются при рестарте пода. Очистка по таймауту (2 часа). ### 9. LLM-клиент через переменные окружения ```python LLM_KEY # API ключ LLM_URL # эндпоинт (по умолчанию https://api.aillm.ru/v1/chat/completions) LLM_MODEL # модель (по умолчанию gpt-oss-120b) ``` ### 10. BOM в CSV ```python buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8')) ``` BOM (`\ufeff`) нужен для корректного открытия CSV в Excel (чтобы русские буквы не ломались). │ ▼ TwoPassObfuscator.obfuscate(files) │ ├── expand_zips → extract_text ├── scan_regex + scan_llm_ner → mapping ├── replace_in_docx / apply_replacements → обфусцированные файлы └── build_mapping_csv + build_zip → (zip_bytes, csv_str) │ ▼ send_file(BytesIO(zip_data), mimetype="application/zip") │ ▼ HTTP 200 + ZIP-архив ``` ### Имена скачиваемых файлов Формат: `drhider_YYYY-MM-DD_HH-MM-SS.zip` и `mapping_YYYY-MM-DD_HH-MM-SS.csv` Время — московское (UTC+3). Генерируется на сервере в момент запроса: ```python # api_bp.py — download() / csv_download() ts = (datetime.now() + timedelta(hours=3)).strftime("%Y-%m-%d_%H-%M-%S") download_name=f"drhider_{ts}.zip" # → Content-Disposition download_name=f"mapping_{ts}.csv" # → Content-Disposition ``` Фронтенд (JS) читает имя из заголовка `Content-Disposition`, **не** хардкодит. --- ## Принципы 1. **Модули — чистые функции.** Где возможно — без классов, без состояния. 2. **Единственный класс — TwoPassObfuscator.** Только он управляет состоянием (mapping, sorted_keys). 3. **Flask — тонкая прослойка.** Только принимает запрос, вызывает `obfuscate_files()`, отдаёт ответ. 4. **Без БД.** Всё в памяти. Никаких внешних зависимостей кроме LLM API. 5. **⛔ Ноль внешних зависимостей на фронтенде.** Никаких CDN, внешних шрифтов, иконок, JS-библиотек. ВСЁ в коде. Страница грузится за 1 запрос. 6. **⛔ ВСЯ обработка данных — на бэкенде.** Фронтенд только отображает и скачивает. Никакой JS-логики для ZIP, парсинга, обфускации. Один запрос → один ответ. 7. **⛔ Пофайловая обработка — на бэкенде.** Файлы обрабатываются строго последовательно (по одному), без параллельных LLM-запросов. Прогресс — бэкенд сообщает статус каждого файла.