fix: load env variables, add fallback for doc conversion, and validate LLM API key
This commit is contained in:
+38
-2
@@ -3,13 +3,49 @@ import os
|
|||||||
|
|
||||||
VERSION = "2.0.24"
|
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_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")
|
LLM_MODEL = os.getenv("LLM_MODEL", "gpt-oss-120b")
|
||||||
|
|
||||||
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
MAX_CONTENT_LENGTH = 200 * 1024 * 1024 # 200 MB
|
||||||
API_KEY = os.getenv("API_KEY", "")
|
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) ──────────────────────────────
|
# ── VM-буфер загрузки (паттерн drhider) ──────────────────────────────
|
||||||
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
|
# Браузер кладёт файл на ВМ через WebDAV (мимо шлюза кластера ~64КБ),
|
||||||
|
|||||||
+35
-10
@@ -9,22 +9,47 @@ import config
|
|||||||
|
|
||||||
|
|
||||||
def _convert(filename: str, data: bytes) -> bytes:
|
def _convert(filename: str, data: bytes) -> bytes:
|
||||||
""".doc → .docx через внешний libreoffice-сервис. Возвращает docx-байты."""
|
""".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:
|
try:
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
config.CONVERT_SERVICE_URL + "/convert",
|
base_url.rstrip("/") + "/convert",
|
||||||
files={"file": (filename, data, "application/msword")},
|
files={"file": (filename, data, "application/msword")},
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException:
|
if resp.status_code == 200:
|
||||||
raise Exception("conversion timeout")
|
|
||||||
if resp.status_code != 200:
|
|
||||||
try:
|
|
||||||
err = resp.json().get("error", "conversion failed")
|
|
||||||
except Exception:
|
|
||||||
err = "conversion failed"
|
|
||||||
raise Exception(err)
|
|
||||||
return resp.content
|
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"):
|
def _store_and_parse(filename: str, data: bytes, batch_id, contract_id, zip_source=None, mime_type="application/octet-stream"):
|
||||||
|
|||||||
@@ -29,7 +29,19 @@ class HttpxLLMClient:
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def complete(self, prompt: str) -> str:
|
def complete(self, prompt: str) -> str:
|
||||||
|
"""Отправить запрос к модели LLM через HTTP POST и вернуть текстовое содержимое ответа.
|
||||||
|
|
||||||
|
Назначение и инварианты:
|
||||||
|
- Проверяет обязательное наличие непустого API-ключа перед формированием HTTP-запроса.
|
||||||
|
- Если ключ отсутствует или пуст, выбрасывает явное исключение ValueError вместо передачи
|
||||||
|
невалидного заголовка Authorization (например 'Bearer '), приводящего к ошибке
|
||||||
|
'Illegal header value' на уровне транспортной библиотеки httpx/httpcore.
|
||||||
|
- Обрезает случайные пробельные символы и переносы строк по краям токена.
|
||||||
|
"""
|
||||||
import httpx
|
import httpx
|
||||||
|
clean_key = (self.key or "").strip()
|
||||||
|
if not clean_key:
|
||||||
|
raise ValueError("LLM_API_KEY is not configured or empty")
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
@@ -41,7 +53,7 @@ class HttpxLLMClient:
|
|||||||
self.url,
|
self.url,
|
||||||
json=payload,
|
json=payload,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {self.key}",
|
"Authorization": f"Bearer {clean_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user