Files
drhider/docs/ARCHITECTURE.md
T
2026-07-15 15:11:11 +04:00

14 KiB
Raw Blame History

Архитектура 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/drhider → api_bp   → drhider.obfuscate_files()
                                           │
                                           ▼
                                    TwoPassObfuscator
                                           │
                              ┌────────────┼────────────┐
                              │            │            │
                         extractor     scanner      replacer
                         (текст)     (regex+LLM)   (замена)
                              │            │            │
                              └────────────┼────────────┘
                                           │
                                      builder
                                      (ZIP+CSV)
                                           │
                                           ▼
                                    application/zip

Поддерживаемые форматы файлов

Формат Обработка Детали
.docx → Markdown Заголовки (H1H3), жирный, курсив, таблицы (python-docx)
.pdf → Markdown Текст + таблицы (pdfplumber), без форматирования
.txt и прочие текстовые → как есть Декодируется UTF-8, ошибки заменяются
.zip → распаковка Файлы внутри обрабатываются рекурсивно. Защита от ZIP-бомб: ≤500 файлов, ratio ≤100:1, ≤500 MB
.doc (бинарный) не поддерживается Штурвал не даёт установить LibreOffice. Если понадобится — просить админов платформы

Ограничение на размер загрузки: 200 MB (MAX_CONTENT_LENGTH).


Двухпроходная обфускация

Проход 1: сбор сущностей

Файлы (.docx, .pdf, .txt, .zip)
    │
    ▼
extractor.expand_zips()          ← распаковать ZIP
    │
    ▼
extractor.convert_pdfs_to_docx() ← PDF → DOCX
    │
    ▼
extractor.extract_text()         ← извлечь текст из каждого файла
    │
    ├──→ scanner.scan_regex()    ← regex: телефоны, email, ИНН, ОГРН, КПП,
    │                               БИК, счета, паспорта, компании, ФИО
    │
    └──→ scanner.scan_llm_ner()  ← LLM: имена, адреса, паспорта
                                      (только если llm_client передан)
    │
    ▼
mapping = {оригинал → фиктивное}  ← глобальный словарь замен

Проход 2: замена

mapping + sorted_keys (по убыванию длины)
    │
    ▼
Для каждого файла:
    │
    ├── .docx → replacer.replace_in_docx()   ← склеить runs → заменить → сохранить
    ├── .doc  → без изменений (бинарный)
    └── .txt/.pdf → replacer.replace_in_text() ← простая строковая замена
    │
    ▼
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/checksum.py

Чистые функции для расчёта контрольных сумм. Без побочных эффектов.

  • checksum_inn10(inn) → 1 цифра
  • checksum_inn12(inn) → 2 цифры
  • checksum_ogrn(ogrn) → 1 цифра

drhider/random_utils.py

Утилиты генерации случайных строк.

  • random_digits(n) → строка из n цифр
  • random_letters(n) → строка из n строчных латинских букв

drhider/generators/

Каждый генератор — независимая функция с сигнатурой generate_xxx(original: str) -> str. Параметр original нужен для извлечения префикса (например, «ИНН » из «ИНН 1234567890»). Генераторы НЕ общаются друг с другом — только импортируют из config, checksum, random_utils.

__init__.py содержит ENTITY_GENERATORS — словарь, связывающий entity_type из ENTITY_PATTERNS с функцией-генератором. Используется в scanner.scan_regex().

drhider/llm_client.py

HTTP-клиент к OpenAI-совместимому API. Читает переменные окружения. Интерфейс: .complete(prompt: str) -> str.

drhider/extractor.py

Три независимые функции (не класс):

  • extract_text(fname, content, ctype)(text, docx_document_or_None)
  • expand_zips(files) → распакованный список
  • convert_pdfs_to_docx(files) → PDF заменены на DOCX

drhider/scanner.py

Две функции (не класс), мутируют переданный mapping: Dict[str, str]:

  • scan_regex(text, mapping) — regex-поиск
  • scan_llm_ner(all_texts, mapping, llm_client) — LLM NER

drhider/replacer.py

Три чистые функции:

  • apply_replacements(text, mapping, sorted_keys) → строка с заменами
  • replace_in_docx(doc, mapping, sorted_keys) → bytes (изменённый DOCX)
  • replace_in_text(text, fname, mapping, sorted_keys) → bytes

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/drhider

__init__.py содержит register_routes(app) — единая точка регистрации.


Поток данных

HTTP POST /api/drhider (multipart/form-data)
    │
    ▼
api_bp.drhider()
    │  request.files.getlist("files")
    │  → [(filename, bytes, mimetype), ...]
    ▼
obfuscate_files(files, llm_client=LLMClient())
    │  Scanner → Extractor → LLM NER → Obfuscator → Replacer → Builder
    ▼
bytes (ZIP-архив с обезличенными документами + mapping.csv)

Требования к Flask-приложению для Штурвала

1. /health — liveness probe

@health_bp.route("/health")
def health():
    return jsonify({"ok": True, "version": "0.0.26"})

Обязательно. Штурвал дёргает /health для readiness/liveness checks. Без этого под не поднимется. Ответ должен быть 200 OK.

2. no_cache на все ответы

@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

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

app.config["MAX_CONTENT_LENGTH"] = 200 * 1024 * 1024  # 200 MB

Без этого Flask использует дефолтный лимит (~1MB) и отвергает docx/pdf.

5. GeneratorExit в SSE-генераторе

try:
    yield f"event: start\n..."
except GeneratorExit:
    return  # клиент отключился — не продолжаем обработку

Критично. При закрытии вкладки браузер рвёт SSE-соединение. Без GeneratorExit генератор продолжает слать запросы в LLM, тратя токены впустую.

6. stream_with_context

from flask import stream_with_context

return Response(stream_with_context(generate()), ...)

Обязательно для SSE. Без этого Flask держит приложение заблокированным на время SSE-соединения, другие запросы не обрабатываются.

7. sys.path для импорта пакета drhider

_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-клиент через переменные окружения

LLM_KEY       # API ключ
LLM_URL       # эндпоинт (по умолчанию https://api.aillm.ru/v1/chat/completions)
LLM_MODEL     # модель (по умолчанию gpt-oss-120b)

10. BOM в CSV

buf.write('\ufeff'.encode('utf-8') + csv_str.encode('utf-8'))

BOM (\ufeff) нужен для корректного открытия CSV в Excel (чтобы русские буквы не ломались). │ ▼ TwoPassObfuscator.obfuscate(files) │ ├── expand_zips → convert_pdfs_to_docx → extract_text ├── scan_regex + scan_llm_ner → mapping ├── replace_in_docx / replace_in_text → обфусцированные файлы └── 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-запросов. Прогресс — бэкенд сообщает статус каждого файла.