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

325 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура 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
```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 → 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-запросов. Прогресс — бэкенд сообщает статус каждого файла.