diff --git a/site/config.py b/site/config.py index aded065..17046e1 100644 --- a/site/config.py +++ b/site/config.py @@ -3,13 +3,49 @@ import os VERSION = "2.0.24" + +def _load_env(): + """Загрузить переменные окружения из .env файла при его наличии. + + Назначение: + Позволяет локальному серверу разработки и фоновым скриптам автоматически + считывать боевые или тестовые параметры (включая LLM_API_KEY и URL сервисов) + без необходимости ручного экспорта в shell. + + Почему так: + Порядок поиска обходит текущую директорию site/, родительский каталог + contracts-flask/ и корень репозитория. Значения уже установленных системных + переменных окружения имеют приоритет и НЕ перезаписываются. + """ + for candidate in [ + os.path.join(os.path.dirname(__file__), ".env"), + os.path.join(os.path.dirname(__file__), "..", ".env"), + os.path.join(os.path.dirname(__file__), "..", "..", ".env"), + ]: + if os.path.isfile(candidate): + try: + with open(candidate, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + k = k.strip() + v = v.strip().strip('"').strip("'") + if k and k not in os.environ: + os.environ[k] = v + except Exception: + pass + + +_load_env() + LLM_URL = os.getenv("LLM_API_URL", "https://api.aillm.ru/v1/chat/completions") -LLM_KEY = os.getenv("LLM_API_KEY", "") +LLM_KEY = os.getenv("LLM_API_KEY") or os.getenv("LLM_KEY", "") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b") MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB API_KEY = os.getenv("API_KEY", "") -CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000") +CONVERT_SERVICE_URL = os.getenv("CONVERT_SERVICE_URL", "https://liberta.containerk8s.dev.nubes.ru") # ── VM-буфер загрузки (паттерн drhider) ────────────────────────────── # Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ), diff --git a/site/routes/upload_bp.py b/site/routes/upload_bp.py index 274d213..6ce58e8 100644 --- a/site/routes/upload_bp.py +++ b/site/routes/upload_bp.py @@ -9,22 +9,47 @@ import config def _convert(filename: str, data: bytes) -> bytes: - """.doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты.""" - try: - resp = httpx.post( - config.CONVERT_SERVICE_URL + "/convert", - files={"file": (filename, data, "application/msword")}, - timeout=120, - ) - except httpx.TimeoutException: - raise Exception("conversion timeout") - if resp.status_code != 200: + """.doc → .docx через внешний libreoffice-сервис с fallback на публичный endpoint. + + Назначение: + Преобразование устаревшего бинарного формата Word 97-2003 (.doc) в современный .docx, + так как встроенный парсер документов работает исключительно со структурой OpenXML. + + Почему реализован fallback: + Внутри Kubernetes-кластера сервис доступен по внутреннему DNS имени + (http://containerk8s.df36c8af-1a95-4623-b551-0d37b731ccca.svc.cluster.local:5000), + однако при запуске вне кластера (локальный сервер разработки, standalone-тесты) + внутреннее доменное имя не резолвится ([Errno -2] Name or service not known). + В таком случае запрос автоматически повторяется к публичному ingress-эндпоинту + https://liberta.containerk8s.dev.nubes.ru, гарантируя прозрачную работу в любых средах. + """ + urls = [config.CONVERT_SERVICE_URL] + fallback = "https://liberta.containerk8s.dev.nubes.ru" + if fallback not in urls: + urls.append(fallback) + + last_err = None + for base_url in urls: + if not base_url: + continue try: - err = resp.json().get("error", "conversion failed") - except Exception: - err = "conversion failed" - raise Exception(err) - return resp.content + resp = httpx.post( + base_url.rstrip("/") + "/convert", + files={"file": (filename, data, "application/msword")}, + timeout=120, + ) + if resp.status_code == 200: + return resp.content + try: + last_err = resp.json().get("error", "conversion failed") + except Exception: + last_err = f"HTTP {resp.status_code}" + except httpx.TimeoutException: + last_err = "conversion timeout" + except Exception as e: + last_err = str(e) + continue + raise Exception(last_err or "conversion failed") def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"): diff --git a/site/services/llm_client.py b/site/services/llm_client.py index ba4da27..b11953d 100644 --- a/site/services/llm_client.py +++ b/site/services/llm_client.py @@ -29,7 +29,19 @@ class HttpxLLMClient: self.timeout = timeout def complete(self, prompt: str) -> str: + """Отправить запрос к модели LLM через HTTP POST и вернуть текстовое содержимое ответа. + + Назначение и инварианты: + - Проверяет обязательное наличие непустого API-ключа перед формированием HTTP-запроса. + - Если ключ отсутствует или пуст, выбрасывает явное исключение ValueError вместо передачи + невалидного заголовка Authorization (например 'Bearer '), приводящего к ошибке + 'Illegal header value' на уровне транспортной библиотеки httpx/httpcore. + - Обрезает случайные пробельные символы и переносы строк по краям токена. + """ import httpx + clean_key = (self.key or "").strip() + if not clean_key: + raise ValueError("LLM_API_KEY is not configured or empty") payload = { "model": self.model, "messages": [{"role": "user", "content": prompt}], @@ -41,7 +53,7 @@ class HttpxLLMClient: self.url, json=payload, headers={ - "Authorization": f"Bearer {self.key}", + "Authorization": f"Bearer {clean_key}", "Content-Type": "application/json", }, )