Skip to content

Commit f706763

Browse files
committed
fix: resolve refactoring issues (Config layer, temp files, frontend stability)
1 parent 1941286 commit f706763

File tree

9 files changed

+232
-28
lines changed

9 files changed

+232
-28
lines changed

backend/config.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""
2+
Централизованная конфигурация приложения.
3+
Читает переменные окружения, предоставляет валидацию и дефолты.
4+
"""
5+
import os
6+
from pathlib import Path
7+
from typing import Optional
8+
from dotenv import load_dotenv
9+
10+
# Загружаем .env из корня проекта
11+
_root_env_path = Path(__file__).resolve().parents[1] / '.env'
12+
load_dotenv(dotenv_path=_root_env_path)
13+
14+
15+
class Config:
16+
"""Централизованный класс конфигурации."""
17+
18+
# LLM API Keys
19+
OPENAI_API_KEY: Optional[str] = os.getenv("OPENAI_API_KEY")
20+
YANDEX_FOLDER_ID: Optional[str] = os.getenv("YANDEX_FOLDER_ID")
21+
YANDEX_API_KEY: Optional[str] = os.getenv("YANDEX_API_KEY")
22+
GIGACHAT_CREDENTIALS: Optional[str] = os.getenv("GIGACHAT_CREDENTIALS")
23+
GIGACHAT_CERT_PATH: Optional[str] = os.getenv("GIGACHAT_CERT_PATH")
24+
25+
# Security & Rate Limiting
26+
API_KEY: Optional[str] = os.getenv("API_KEY") # опционально, если не задан - проверка отключена
27+
RATE_LIMIT_WINDOW_SEC: int = int(os.getenv("RATE_LIMIT_WINDOW_SEC", "60"))
28+
RATE_LIMIT_MAX_REQ: int = int(os.getenv("RATE_LIMIT_MAX_REQ", "60"))
29+
30+
# Flask
31+
FLASK_DEBUG: bool = os.getenv("FLASK_DEBUG", "0") in ("1", "true", "True")
32+
33+
# Test Mode
34+
TEST_MODE: bool = os.getenv("TEST_MODE", "false").lower() in ("true", "1")
35+
36+
# Cache
37+
ANALYSIS_CACHE_TTL_SEC: int = int(os.getenv("ANALYSIS_CACHE_TTL_SEC", "600"))
38+
ANALYSIS_CACHE_MAX: int = int(os.getenv("ANALYSIS_CACHE_MAX", "256"))
39+
40+
# Temp files cleanup (minutes)
41+
TEMP_CLEANUP_AGE_MIN: int = int(os.getenv("TEMP_CLEANUP_AGE_MIN", "30"))
42+
43+
@classmethod
44+
def validate(cls) -> None:
45+
"""
46+
Проверяет наличие обязательных переменных окружения.
47+
В TEST_MODE не требует LLM ключей.
48+
49+
Raises:
50+
ValueError: если отсутствуют обязательные переменные
51+
"""
52+
if cls.TEST_MODE:
53+
return # В тестовом режиме ключи не требуются
54+
55+
missing = []
56+
57+
# Проверяем только если не TEST_MODE
58+
# OpenAI опционален (может использоваться только Yandex/Giga)
59+
# Но хотя бы один провайдер должен быть настроен
60+
has_any_provider = (
61+
cls.OPENAI_API_KEY or
62+
(cls.YANDEX_API_KEY and cls.YANDEX_FOLDER_ID) or
63+
cls.GIGACHAT_CREDENTIALS
64+
)
65+
66+
if not has_any_provider:
67+
missing.append("At least one LLM provider must be configured (OPENAI_API_KEY, YANDEX_API_KEY+YANDEX_FOLDER_ID, or GIGACHAT_CREDENTIALS)")
68+
69+
if missing:
70+
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
71+
72+
@classmethod
73+
def get_debug_flag(cls) -> bool:
74+
"""Возвращает флаг debug для Flask."""
75+
return cls.FLASK_DEBUG
76+

backend/llm/gigachat_helper.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
from gigachat import GigaChat
44
from gigachat.models import Chat, Messages, MessagesRole
5+
from backend.config import Config
56

67
# Настройка логирования
78
logger = logging.getLogger(__name__)
@@ -12,12 +13,12 @@ def get_giga_response(user_prompt: str, model: str = "GigaChat:latest") -> str:
1213
В случае ошибки возвращает сообщение об ошибке.
1314
"""
1415
# В тестовом режиме возвращаем заглушку
15-
if os.getenv("TEST_MODE", "false").lower() == "true":
16+
if Config.TEST_MODE:
1617
return "Тестовый режим: Здесь будет ответ от GigaChat. Для реальной работы укажите GIGACHAT_CREDENTIALS в .env"
1718

1819
try:
19-
credentials = os.getenv("GIGACHAT_CREDENTIALS")
20-
cert_path = os.getenv("GIGACHAT_CERT_PATH", "russian_trusted_root_ca.cer")
20+
credentials = Config.GIGACHAT_CREDENTIALS
21+
cert_path = Config.GIGACHAT_CERT_PATH or "russian_trusted_root_ca.cer"
2122

2223
if not credentials:
2324
logger.error("Не найдены учетные данные GigaChat в переменных окружения")

backend/llm/main_processor.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Этот файл будет центральной точкой для вызова любой LLM
2-
import os
2+
from backend.config import Config
33
from . import yandex_gpt_helper, gigachat_helper, openai_helper
44

55
def get_analysis(provider: str, model: str, table_data: str) -> str:
@@ -12,7 +12,7 @@ def get_analysis(provider: str, model: str, table_data: str) -> str:
1212
:return: Текстовый отчет от LLM
1313
"""
1414
# Проверяем тестовый режим
15-
if os.getenv("TEST_MODE", "false").lower() == "true":
15+
if Config.TEST_MODE:
1616
return f"""Тестовый режим активен. Анализ данных:
1717
1818
Провайдер: {provider}

backend/llm/openai_helper.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import os
21
import logging
32
from openai import OpenAI
3+
from backend.config import Config
44

55
logger = logging.getLogger(__name__)
66

@@ -11,10 +11,10 @@ def get_openai_response(user_prompt: str, model: str = "gpt-4", retries=3) -> st
1111
Отправляет запрос к OpenAI и возвращает ответ.
1212
"""
1313
# В тестовом режиме возвращаем заглушку
14-
if os.getenv("TEST_MODE", "false").lower() == "true":
14+
if Config.TEST_MODE:
1515
return "Тестовый режим: Здесь будет ответ от OpenAI. Для реальной работы укажите OPENAI_API_KEY в .env"
1616

17-
api_key = os.getenv("OPENAI_API_KEY")
17+
api_key = Config.OPENAI_API_KEY
1818
if not api_key:
1919
logger.error("Не найден API ключ OpenAI в переменных окружения")
2020
return "Ошибка конфигурации OpenAI. Обратитесь к администратору."

backend/llm/yandex_gpt_helper.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import os
21
import logging
32
import requests
43
from typing import Optional
54
import json
5+
from backend.config import Config
66

77
logger = logging.getLogger(__name__)
88

@@ -14,11 +14,11 @@ def get_yandex_response(user_prompt: str, model: str = "yandexgpt-lite", retries
1414
Предусмотрены повторные попытки в случае ошибки.
1515
"""
1616
# В тестовом режиме возвращаем заглушку
17-
if os.getenv("TEST_MODE", "false").lower() == "true":
17+
if Config.TEST_MODE:
1818
return "Тестовый режим: Здесь будет ответ от YandexGPT. Для реальной работы укажите YANDEX_FOLDER_ID и YANDEX_API_KEY в .env"
1919

20-
folder_id = os.getenv("YANDEX_FOLDER_ID")
21-
iam_token = os.getenv("YANDEX_API_KEY")
20+
folder_id = Config.YANDEX_FOLDER_ID
21+
iam_token = Config.YANDEX_API_KEY
2222

2323
logger.debug(f"Используется YANDEX_FOLDER_ID: {folder_id}")
2424

backend/pdf_server.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
import os
88
import json
99
from typing import List, Optional
10-
from dotenv import load_dotenv
1110
from pathlib import Path
1211
from flask_cors import CORS
1312
import logging
1413
from werkzeug.utils import secure_filename
1514
from backend.types import BasicAnalysis
1615
from bleach.sanitizer import Cleaner
1716
from backend.errors import register_error_handlers, ValidationError
17+
from backend.config import Config
1818
import time
1919
import hashlib
2020
from itertools import islice
@@ -24,11 +24,7 @@
2424
logging.basicConfig(level=logging.DEBUG)
2525
logger = logging.getLogger(__name__)
2626

27-
# Загружаем переменные окружения как можно раньше, из КОРНЯ проекта (единый .env)
28-
_root_env_path = Path(__file__).resolve().parents[1] / '.env'
29-
load_dotenv(dotenv_path=_root_env_path)
30-
31-
# Импортируем LLM-обработчик после загрузки .env, чтобы учитывался TEST_MODE и ключи
27+
# Импортируем LLM-обработчик после загрузки Config
3228
from llm.main_processor import get_analysis
3329

3430
app = Flask(__name__)
@@ -74,9 +70,9 @@ def _block_external_url_fetcher(url):
7470
})
7571

7672
# Опциональная API-авторизация и наивный rate limiting
77-
API_KEY = os.getenv("API_KEY") # если не задан, проверка отключена
78-
RATE_LIMIT_WINDOW_SEC = int(os.getenv("RATE_LIMIT_WINDOW_SEC", "60"))
79-
RATE_LIMIT_MAX_REQ = int(os.getenv("RATE_LIMIT_MAX_REQ", "60"))
73+
API_KEY = Config.API_KEY
74+
RATE_LIMIT_WINDOW_SEC = Config.RATE_LIMIT_WINDOW_SEC
75+
RATE_LIMIT_MAX_REQ = Config.RATE_LIMIT_MAX_REQ
8076
_rate_limit_store: dict[str, list[float]] = {}
8177

8278
def _client_id() -> str:
@@ -103,8 +99,8 @@ def _security_and_rate_limit():
10399

104100

105101
# Кэш анализа по (provider, model, dataset_hash)
106-
ANALYSIS_CACHE_TTL_SEC = int(os.getenv("ANALYSIS_CACHE_TTL_SEC", "600"))
107-
ANALYSIS_CACHE_MAX = int(os.getenv("ANALYSIS_CACHE_MAX", "256"))
102+
ANALYSIS_CACHE_TTL_SEC = Config.ANALYSIS_CACHE_TTL_SEC
103+
ANALYSIS_CACHE_MAX = Config.ANALYSIS_CACHE_MAX
108104
_analysis_cache: dict[str, tuple[float, str]] = {}
109105

110106

@@ -133,7 +129,7 @@ def _put_cached_analysis(key: str, value: str) -> None:
133129

134130

135131
# Простое хранилище загруженных датасетов (только для lifetime процесса)
136-
# dataset_id -> {'path': str, 'kind': 'csv'|'excel'|'pdf', 'columns': list[str], 'total_rows': int}
132+
# dataset_id -> {'dir': Path, 'path': str, 'kind': 'csv'|'excel'|'pdf', 'columns': list[str], 'total_rows': int, 'created_at': float}
137133
_datasets: Dict[str, Dict[str, Any]] = {}
138134

139135
def _make_dataset_id(file_path: str) -> str:
@@ -321,9 +317,10 @@ def upload_file():
321317
# Ограничиваем размер страницы
322318
page_size = min(page_size, 5000) # Максимум 5000 строк на страницу
323319

324-
# Сохраняем файл во временную директорию
320+
# Создаём уникальную директорию для каждого загруженного файла
321+
temp_dir = Path(tempfile.mkdtemp(prefix="vcb03_"))
325322
filename = secure_filename(file.filename) if file.filename else 'uploaded_file.csv'
326-
temp_file_name = os.path.join(tempfile.gettempdir(), filename)
323+
temp_file_name = str(temp_dir / filename)
327324
file.save(temp_file_name)
328325

329326
# Определяем тип файла и обрабатываем соответственно
@@ -397,10 +394,12 @@ def upload_file():
397394

398395
# Сохраняем метаданные датасета для последующих запросов страниц
399396
_datasets[dataset_id] = {
397+
'dir': temp_dir,
400398
'path': temp_file_name,
401399
'kind': kind,
402400
'columns': columns,
403401
'total_rows': total_rows,
402+
'created_at': time.time(),
404403
}
405404

406405
# Формируем ответ
@@ -618,6 +617,31 @@ def get_upload_page():
618617
logger.exception("Error in /api/upload/page")
619618
raise
620619

620+
def _cleanup_old_datasets():
621+
"""Удаляет старые датасеты из _datasets и их директории."""
622+
now = time.time()
623+
age_sec = Config.TEMP_CLEANUP_AGE_MIN * 60
624+
to_remove = []
625+
for dataset_id, meta in _datasets.items():
626+
if now - meta.get('created_at', 0) > age_sec:
627+
to_remove.append(dataset_id)
628+
# Удаляем директорию
629+
temp_dir = meta.get('dir')
630+
if temp_dir and Path(temp_dir).exists():
631+
try:
632+
import shutil
633+
shutil.rmtree(temp_dir, ignore_errors=True)
634+
except Exception as e:
635+
logger.warning(f"Failed to remove temp dir {temp_dir}: {e}")
636+
for dataset_id in to_remove:
637+
_datasets.pop(dataset_id, None)
638+
639+
@app.before_request
640+
def _cleanup_before_request():
641+
"""Периодическая очистка старых датасетов."""
642+
# Очищаем каждые 5 минут (проверяем каждый 100-й запрос примерно)
643+
if len(_datasets) > 0 and time.time() % 300 < 1:
644+
_cleanup_old_datasets()
645+
621646
if __name__ == '__main__':
622-
debug_flag = os.getenv('FLASK_DEBUG', '0') in ('1', 'true', 'True')
623-
app.run(host='0.0.0.0', port=5000, debug=debug_flag)
647+
app.run(host='0.0.0.0', port=5000, debug=Config.get_debug_flag())

backend/requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ openpyxl # для работы с Excel
1717
Pillow # для работы с изображениями
1818
pytesseract # для OCR
1919
python-docx # для структурированного текста
20+
# Для тестирования
21+
pytest
22+
pytest-cov

refactoring-2.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
### Refactoring Plan v2
2+
3+
Цель: закрыть дефекты, обнаруженные при проверке `refactoring.md`, и вывести проект на состояние, пригодное для сборки, тестирования и безопасного деплоя.
4+
5+
---
6+
7+
#### 1. Config & Secrets Hygiene (CRITICAL)
8+
- **Задачи**
9+
1. Ввести `backend/config.py` с классом `Config`: чтение переменных окружения, дефолты, `validate()` с обязательными ключами (LLM API, security options, rate-limit).
10+
2. Все импорты (`pdf_server`, `llm/*`) переводим на `from backend.config import Config`. Убираем прямые `load_dotenv` и обращения к `os.getenv`.
11+
3. Обновляем `.env.example`, удаляем реальные ключи из `.env` / `backend/.env`, документируем процесс в README (как скопировать `.env.example`).
12+
4. Добавляем sanity-check команду в Make/README: `python -c "from backend.config import Config; Config.validate()"`.
13+
- **Верификация**: `pytest tests/config` (новые тесты на валидацию) + ручной запуск `Config.validate()`.
14+
15+
#### 2. Backend: CSV Streaming & Dataset Delta (HIGH)
16+
- **Chunked CSV**
17+
- Переписать `_csv_get_page`: использовать `pd.read_csv(..., skiprows=start_row, nrows=page_size)` только при необходимости, иначе читать через iterator + `islice` (не ловим исключение, а выбираем стратегию по размеру).
18+
- Добавить benchmark/pytest (`tests/test_upload_pagination.py`) на файлы 50k+ строк (можно синтетический CSV).
19+
- **Dataset registry**
20+
- `upload_file`: сохранять файл в уникальную директорию (`tempfile.mkdtemp(prefix="vcb03_")`), хранить путь к каталогу/файлу в `_datasets`.
21+
- Добавить фоновой clean-up (например, cron-like при `before_request` → удаляем записи старше N минут).
22+
- **LLM delta flow**
23+
- `/api/analyze` принимает `dataset_id`, `page_cursor`, `table_data_delta`. При наличии dataset_id сервер сам читает нужный кусок (через `_datasets`).
24+
- Кэш-ключ строим из `dataset_id + provider + model`.
25+
- На фронте `handleLoadMore` отправляет только дельту и datasetId.
26+
- **Верификация**: интеграционные тесты `tests/test_analyze_delta.py`, `tests/test_large_csv_stream.py`.
27+
28+
#### 3. Frontend Stability (HIGH)
29+
- **AnalysisResult rebuild**
30+
- Разделить состояние `autoCharts`: оставить `const [autoCharts, setAutoCharts]` и переименовать значение из hook (`const generatedCharts = useAutoCharts(...)`), либо избавиться от локального стейта — цель: устранить двойное объявление.
31+
- Прогнать `npm run build` (должен отработать).
32+
- **Virtualized table**
33+
- Перейти на `TableBody` с `component={List}` (MUI pattern) или собственный контейнер: `<Table component={Paper}>` + `<Box>` внутри для `react-window`. Важно не вставлять `<div>` напрямую в `<tbody>`.
34+
- Покрыть snapshot/RTL тестом (`VirtualizedTable.test.tsx`) проверяющим структуру.
35+
- **Logging**
36+
- Завести флаг `REACT_APP_DEBUG`, завраппить `console.log`/`console.debug`. При PROD сборке флаг выключен.
37+
- **Верификация**: `npm run lint`, `npm run test -- --runInBand`, `npm run build`.
38+
39+
#### 4. Report & PDF Security (MEDIUM)
40+
- **HTML sanitization** уже есть, но нужно:
41+
- Добавить allowlist CSS (убрать inline `background:url` и др.).
42+
- Написать тест `test_report_sanitize_blocks_styles` (проверяет, что `style` с внешними URL режется).
43+
- Опционально добавить конфиг `ALLOW_REPORT_INLINE_STYLE` для dev.
44+
- **Верификация**: `pytest tests/test_report_sanitize.py`.
45+
46+
#### 5. Temp File Safety (MEDIUM)
47+
- В каждом upload создаём уникальную директорию (`tmpdir = Path(tempfile.mkdtemp())`; `file_path = tmpdir / secure_filename`).
48+
- `_datasets[dataset_id]` хранит `{"dir": tmpdir, "file": file_path, "created_at": time.time()}`.
49+
- При выдаче следующей страницы проверяем, что файл существует, иначе возвращаем `410 Gone`.
50+
- Фоновый cleanup (см. пункт 2) удаляет каталог и запись.
51+
- **Верификация**: интеграционный тест, имитирующий два одновременных аплоада с одинаковыми именами.
52+
53+
#### 6. Testing Infrastructure (MEDIUM)
54+
- Добавить `pytest` и плагины в `backend/requirements.txt`; создать `requirements-dev.txt`.
55+
- CI: GitHub Actions workflow → `pip install -r requirements.txt -r requirements-dev.txt`, `pytest`, `npm ci`, `npm run test`, `npm run build`.
56+
- Для WeasyPrint/pd зависимостей описать в README требуемые system packages.
57+
58+
#### 7. Documentation & Developer UX (LOW)
59+
- README обновить разделами: настройки env, запуск тестов, работа с большими CSV, политика temp cleanup.
60+
- Добавить `docs/architecture.md` с описанием dataset cache/delta pipeline и новых API контрактов.
61+
- Обновить `refactoring.md` (или завести changelog) после реализации.
62+
63+
---
64+
65+
##### Риски и контрольные точки
66+
- **Большие CSV**: измерить память/время до и после (добавить раздел “Performance Benchmarks”).
67+
- **LLM квоты**: убедиться, что кэш очищается (TTL + max size).
68+
- **PDF**: только whitelisted стили → коммуникация с UX, чтобы не сломать верстку.
69+
70+
##### Общая последовательность
71+
1. Config + Secrets → чтобы разработка встала на единый конфиг.
72+
2. Backend streaming + temp dirs → влияет на API.
73+
3. Frontend адаптация под новые API + фиксы сборки.
74+
4. Тесты/CI → фиксируем регрессии.
75+
5. Документация и cleanup.
76+
77+
Каждый этап завершается прогоном pytest + npm build и апдейтом отчета в `refactoring-report.md`.

0 commit comments

Comments
 (0)