Files
drhider/docs/ARCHITECTURE.md
T

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/upload (→ /api/process_stream/<sid>) → 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 (бинарный) → 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_bpPOST /api/upload, GET /api/process_stream/<sid> (SSE), POST /api/process/<sid>, GET /api/download/<sid>, GET /api/csv/<sid>

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


Поток данных

1. POST /api/upload (multipart/form-data)
    │  request.files.getlist("files")
    │  → session.add_file(...) для каждого файла
    ▼
   JSON {ok, session: <sid>, count}

2. GET /api/process_stream/<sid> (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

@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 → 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-запросов. Прогресс — бэкенд сообщает статус каждого файла.