From b3e9f62db327585b395a13093b0424b232ce92e9 Mon Sep 17 00:00:00 2001 From: xds Date: Sun, 22 Mar 2026 14:52:10 +0300 Subject: [PATCH] init --- .env | 4 +- backend/app/config.py | 2 + backend/app/routers/admin.py | 4 + backend/app/routers/calculate.py | 2 +- backend/app/services/ai_advisor.py | 159 +++++++++++++++------ backend/requirements.txt | 1 - frontend/src/components/HeroSection.vue | 2 +- frontend/src/components/PrintSettings.vue | 2 +- frontend/src/views/admin/AdminSettings.vue | 9 ++ 9 files changed, 133 insertions(+), 52 deletions(-) diff --git a/.env b/.env index 6ef9630..a2b9dd7 100644 --- a/.env +++ b/.env @@ -8,4 +8,6 @@ MINIO_ENDPOINT=31.59.58.220:9000 MINIO_ACCESS_KEY=admin MINIO_SECRET_KEY=SuperSecretPassword123! MINIO_BUCKET=filam3d -MINIO_SECURE=false \ No newline at end of file +MINIO_SECURE=false +AI_PROXY_URL=http://82.22.174.14:8001 +AI_PROXY_SALT=AbVJUkwTPcUWJWhPzmjXb5p4SYyKmYC5m1uVW7Dhi7o \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py index 6528fa5..b0e4643 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,6 +4,8 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d" GOOGLE_API_KEY: str = "" + AI_PROXY_URL: str = "http://82.22.174.14:8001" + AI_PROXY_SALT: str = "change_me_in_production" TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_CHAT_ID: str = "" UPLOAD_DIR: str = "/app/uploads" diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 44333f0..c490163 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -535,6 +535,10 @@ DEFAULT_SETTINGS = [ {"key": "painting_cost", "value": "500", "description": "Стоимость покраски (руб/шт)"}, {"key": "threading_cost", "value": "200", "description": "Стоимость нарезки резьбы (руб/шт)"}, {"key": "acetone_cost", "value": "400", "description": "Стоимость ацетоновой обработки (руб/шт)"}, + {"key": "ai_use_proxy", "value": "true", "description": "Использовать AI-прокси (true/false)"}, + {"key": "ai_proxy_url", "value": "", "description": "URL AI-прокси"}, + {"key": "ai_proxy_salt", "value": "", "description": "Секретная соль для AI-прокси"}, + {"key": "ai_direct_api_key", "value": "", "description": "Google API Key для прямого подключения"}, {"key": "company_name", "value": "Filam3D", "description": "Название компании"}, {"key": "company_phone", "value": "", "description": "Телефон компании"}, {"key": "company_email", "value": "", "description": "Email компании"}, diff --git a/backend/app/routers/calculate.py b/backend/app/routers/calculate.py index 32fe11e..ce2a0fd 100644 --- a/backend/app/routers/calculate.py +++ b/backend/app/routers/calculate.py @@ -56,7 +56,7 @@ async def calculate( logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, color='%s', multicolor=%s, post_processing='%s'", material_id, infill_percent, layer_height_mm, quantity, color, multicolor, post_processing) - if not 10 <= infill_percent <= 100: + if not 5 <= infill_percent <= 100: logger.warning("Invalid infill_percent: %d", infill_percent) raise HTTPException(400, "infill_percent должен быть от 10 до 100") if not 0.08 <= layer_height_mm <= 0.4: diff --git a/backend/app/services/ai_advisor.py b/backend/app/services/ai_advisor.py index 3361fe1..c5f8469 100644 --- a/backend/app/services/ai_advisor.py +++ b/backend/app/services/ai_advisor.py @@ -1,11 +1,14 @@ +import hashlib import json import logging import time -from google import genai -from google.genai import types +import httpx +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.models.app_settings import AppSettings logger = logging.getLogger("app.services.ai_advisor") @@ -34,71 +37,133 @@ SYSTEM_PROMPT = """ """ +async def _get_setting(db: AsyncSession, key: str, default: str = "") -> str: + result = await db.execute(select(AppSettings.value).where(AppSettings.key == key)) + row = result.scalar_one_or_none() + return row if row is not None else default + + +def _make_proxy_signature(salt: str) -> tuple[str, int]: + timestamp = int(time.time()) + hash_input = f"{timestamp}{salt}".encode() + signature = hashlib.sha256(hash_input).hexdigest() + return signature, timestamp + + +async def _call_via_proxy(proxy_url: str, proxy_salt: str, messages: list[dict]) -> str: + """Send request through AI proxy.""" + signature, timestamp = _make_proxy_signature(proxy_salt) + headers = { + "x-signature": signature, + "x-timestamp": str(timestamp), + } + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post( + f"{proxy_url.rstrip('/')}/generate_text", + json={"messages": messages}, + headers=headers, + ) + if resp.status_code != 200: + logger.error("AI proxy error %d: %s", resp.status_code, resp.text[:500]) + raise ValueError(f"AI proxy вернул ошибку: {resp.status_code}") + + data = resp.json() + text = data.get("response", "") + if not text: + raise ValueError(f"AI proxy вернул пустой ответ (finish_reason={data.get('finish_reason')})") + return text + + +async def _call_direct(api_key: str, system: str, user_message: str) -> str: + """Call Google GenAI directly via REST (no SDK dependency).""" + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}" + payload = { + "system_instruction": {"parts": [{"text": system}]}, + "contents": [{"role": "user", "parts": [{"text": user_message}]}], + "generationConfig": {"temperature": 0.3, "maxOutputTokens": 1024}, + } + async with httpx.AsyncClient(timeout=60.0) as client: + resp = await client.post(url, json=payload) + if resp.status_code != 200: + logger.error("Google API error %d: %s", resp.status_code, resp.text[:500]) + raise ValueError(f"Google API ошибка: {resp.status_code}") + + data = resp.json() + candidates = data.get("candidates", []) + if not candidates: + raise ValueError("Google API вернул пустой ответ") + parts = candidates[0].get("content", {}).get("parts", []) + return parts[0].get("text", "") if parts else "" + + +def _parse_json_response(text: str) -> dict: + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find("{") + end = text.rfind("}") + 1 + if start != -1 and end > start: + return json.loads(text[start:end]) + raise ValueError("AI вернул невалидный JSON") + + async def get_material_recommendation( task_description: str, materials_data: list[dict], budget_preference: str = "optimal", file_info: dict | None = None, + db: AsyncSession | None = None, ) -> dict: - """Get material recommendation from Google Gemini API.""" + """Get material recommendation — via proxy or direct, based on DB settings.""" logger.info("=== AI Advisor request ===") logger.info("Task: %s", task_description) - logger.info("Budget preference: %s", budget_preference) - logger.info("File info: %s", file_info) - logger.info("Materials count: %d", len(materials_data)) - if not settings.GOOGLE_API_KEY: - logger.error("GOOGLE_API_KEY is not configured") - raise ValueError("GOOGLE_API_KEY not configured") + # Read settings from DB, fallback to config + use_proxy = True + proxy_url = settings.AI_PROXY_URL + proxy_salt = settings.AI_PROXY_SALT + direct_api_key = settings.GOOGLE_API_KEY + + if db: + use_proxy_str = await _get_setting(db, "ai_use_proxy", "true") + use_proxy = use_proxy_str.lower() in ("true", "1", "yes") + db_proxy_url = await _get_setting(db, "ai_proxy_url") + db_proxy_salt = await _get_setting(db, "ai_proxy_salt") + db_api_key = await _get_setting(db, "ai_direct_api_key") + if db_proxy_url: + proxy_url = db_proxy_url + if db_proxy_salt: + proxy_salt = db_proxy_salt + if db_api_key: + direct_api_key = db_api_key materials_json = json.dumps(materials_data, ensure_ascii=False, indent=2) system = SYSTEM_PROMPT.format(materials_json=materials_json) - logger.debug("System prompt length: %d chars", len(system)) user_message = f"Описание задачи: {task_description}\nПредпочтение по бюджету: {budget_preference}" if file_info: user_message += f"\nИнформация о модели: {json.dumps(file_info, ensure_ascii=False)}" - logger.debug("User message: %s", user_message) - logger.info("Sending request to Gemini API (model: gemini-2.0-flash)...") start_time = time.time() - client = genai.Client(api_key=settings.GOOGLE_API_KEY) - response = await client.aio.models.generate_content( - model="gemini-3-flash-preview", - contents=user_message, - config=types.GenerateContentConfig( - system_instruction=system, - max_output_tokens=1024, - temperature=0.3, - ), - ) + if use_proxy: + logger.info("Mode: PROXY (%s)", proxy_url) + if not proxy_url: + raise ValueError("AI proxy URL не настроен") + messages = [{"role": "user", "content": system + "\n\n" + user_message}] + response_text = await _call_via_proxy(proxy_url, proxy_salt, messages) + else: + logger.info("Mode: DIRECT (Google API)") + if not direct_api_key: + raise ValueError("Google API Key не настроен") + response_text = await _call_direct(direct_api_key, system, user_message) elapsed = time.time() - start_time - logger.info("Gemini API responded in %.2f seconds", elapsed) - - response_text = response.text + logger.info("AI responded in %.2f seconds", elapsed) logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500]) - try: - result = json.loads(response_text) - logger.info("Response parsed as JSON successfully") - logger.info("Recommended material: id=%s, name=%s", - result.get("recommended_material_id"), result.get("recommended_material_name")) - logger.info("Alternatives: %d, Questions: %d", - len(result.get("alternatives", [])), len(result.get("questions", []))) - logger.info("=== AI Advisor complete ===") - return result - except json.JSONDecodeError: - logger.warning("Direct JSON parse failed, trying to extract JSON from response...") - start = response_text.find("{") - end = response_text.rfind("}") + 1 - if start != -1 and end > start: - extracted = response_text[start:end] - logger.debug("Extracted JSON substring [%d:%d]: %s", start, end, extracted[:300]) - result = json.loads(extracted) - logger.info("Extracted JSON parsed successfully") - logger.info("=== AI Advisor complete ===") - return result - logger.error("Failed to extract JSON from AI response: %s", response_text[:200]) - raise ValueError("AI вернул невалидный JSON") + result = _parse_json_response(response_text) + logger.info("Recommended: id=%s, name=%s", + result.get("recommended_material_id"), result.get("recommended_material_name")) + logger.info("=== AI Advisor complete ===") + return result diff --git a/backend/requirements.txt b/backend/requirements.txt index 566aabc..33f7514 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,6 +10,5 @@ numpy==2.2.1 python-multipart==0.0.20 httpx==0.28.1 minio==7.2.12 -google-genai==1.14.0 pyjwt==2.10.1 bcrypt==4.2.1 diff --git a/frontend/src/components/HeroSection.vue b/frontend/src/components/HeroSection.vue index fb3d3c8..8a7a6b9 100644 --- a/frontend/src/components/HeroSection.vue +++ b/frontend/src/components/HeroSection.vue @@ -5,7 +5,7 @@ 3D-печать на заказ
с мгновенным расчётом

- Загрузите 3D-модель — получите точную стоимость за секунды. + Загрузите 3D-модель — получите приблизительную стоимость за секунды. 7 материалов, AI-подбор, от прототипа до серии в 500 штук.

diff --git a/frontend/src/components/PrintSettings.vue b/frontend/src/components/PrintSettings.vue index 9966e9e..afe1b62 100644 --- a/frontend/src/components/PrintSettings.vue +++ b/frontend/src/components/PrintSettings.vue @@ -15,7 +15,7 @@ type="range" :value="store.settings.infill_percent" @input="store.settings.infill_percent = +$event.target.value" - min="10" max="100" step="10" + min="10" max="100" step="5" class="w-full accent-primary-600" />
diff --git a/frontend/src/views/admin/AdminSettings.vue b/frontend/src/views/admin/AdminSettings.vue index 58196fb..37872db 100644 --- a/frontend/src/views/admin/AdminSettings.vue +++ b/frontend/src/views/admin/AdminSettings.vue @@ -110,6 +110,15 @@ const settingsGroups = [ { key: 'acetone_smoothing_cost', label: 'Ацетоновая обработка (руб/шт)', placeholder: '400' }, ], }, + { + title: 'AI-ассистент', + items: [ + { key: 'ai_use_proxy', label: 'Использовать AI-прокси (true/false)', placeholder: 'true' }, + { key: 'ai_proxy_url', label: 'URL прокси', placeholder: 'http://82.22.174.14:8001' }, + { key: 'ai_proxy_salt', label: 'Секретная соль прокси', placeholder: '' }, + { key: 'ai_direct_api_key', label: 'Google API Key (прямое подключение)', placeholder: '' }, + ], + }, { title: 'Уведомления', items: [