Files
drhider/docs/ARCHITECTURE.md
T

321 lines
14 KiB
Markdown
Raw 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/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_bp``POST /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
```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-запросов. Прогресс — бэкенд сообщает статус каждого файла.