This commit is contained in:
xds
2026-03-22 14:52:10 +03:00
parent 466a27907a
commit b3e9f62db3
9 changed files with 133 additions and 52 deletions

4
.env
View File

@@ -8,4 +8,6 @@ MINIO_ENDPOINT=31.59.58.220:9000
MINIO_ACCESS_KEY=admin MINIO_ACCESS_KEY=admin
MINIO_SECRET_KEY=SuperSecretPassword123! MINIO_SECRET_KEY=SuperSecretPassword123!
MINIO_BUCKET=filam3d MINIO_BUCKET=filam3d
MINIO_SECURE=false MINIO_SECURE=false
AI_PROXY_URL=http://82.22.174.14:8001
AI_PROXY_SALT=AbVJUkwTPcUWJWhPzmjXb5p4SYyKmYC5m1uVW7Dhi7o

View File

@@ -4,6 +4,8 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d" DATABASE_URL: str = "postgresql+asyncpg://print3d:P3D_PASSWORD@31.59.58.220:5432/print3d"
GOOGLE_API_KEY: str = "" 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_BOT_TOKEN: str = ""
TELEGRAM_CHAT_ID: str = "" TELEGRAM_CHAT_ID: str = ""
UPLOAD_DIR: str = "/app/uploads" UPLOAD_DIR: str = "/app/uploads"

View File

@@ -535,6 +535,10 @@ DEFAULT_SETTINGS = [
{"key": "painting_cost", "value": "500", "description": "Стоимость покраски (руб/шт)"}, {"key": "painting_cost", "value": "500", "description": "Стоимость покраски (руб/шт)"},
{"key": "threading_cost", "value": "200", "description": "Стоимость нарезки резьбы (руб/шт)"}, {"key": "threading_cost", "value": "200", "description": "Стоимость нарезки резьбы (руб/шт)"},
{"key": "acetone_cost", "value": "400", "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_name", "value": "Filam3D", "description": "Название компании"},
{"key": "company_phone", "value": "", "description": "Телефон компании"}, {"key": "company_phone", "value": "", "description": "Телефон компании"},
{"key": "company_email", "value": "", "description": "Email компании"}, {"key": "company_email", "value": "", "description": "Email компании"},

View File

@@ -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'", 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) 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) logger.warning("Invalid infill_percent: %d", infill_percent)
raise HTTPException(400, "infill_percent должен быть от 10 до 100") raise HTTPException(400, "infill_percent должен быть от 10 до 100")
if not 0.08 <= layer_height_mm <= 0.4: if not 0.08 <= layer_height_mm <= 0.4:

View File

@@ -1,11 +1,14 @@
import hashlib
import json import json
import logging import logging
import time import time
from google import genai import httpx
from google.genai import types from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.models.app_settings import AppSettings
logger = logging.getLogger("app.services.ai_advisor") 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( async def get_material_recommendation(
task_description: str, task_description: str,
materials_data: list[dict], materials_data: list[dict],
budget_preference: str = "optimal", budget_preference: str = "optimal",
file_info: dict | None = None, file_info: dict | None = None,
db: AsyncSession | None = None,
) -> dict: ) -> 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("=== AI Advisor request ===")
logger.info("Task: %s", task_description) 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: # Read settings from DB, fallback to config
logger.error("GOOGLE_API_KEY is not configured") use_proxy = True
raise ValueError("GOOGLE_API_KEY not configured") 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) materials_json = json.dumps(materials_data, ensure_ascii=False, indent=2)
system = SYSTEM_PROMPT.format(materials_json=materials_json) 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}" user_message = f"Описание задачи: {task_description}\nПредпочтение по бюджету: {budget_preference}"
if file_info: if file_info:
user_message += f"\nИнформация о модели: {json.dumps(file_info, ensure_ascii=False)}" 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() start_time = time.time()
client = genai.Client(api_key=settings.GOOGLE_API_KEY) if use_proxy:
response = await client.aio.models.generate_content( logger.info("Mode: PROXY (%s)", proxy_url)
model="gemini-3-flash-preview", if not proxy_url:
contents=user_message, raise ValueError("AI proxy URL не настроен")
config=types.GenerateContentConfig( messages = [{"role": "user", "content": system + "\n\n" + user_message}]
system_instruction=system, response_text = await _call_via_proxy(proxy_url, proxy_salt, messages)
max_output_tokens=1024, else:
temperature=0.3, 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 elapsed = time.time() - start_time
logger.info("Gemini API responded in %.2f seconds", elapsed) logger.info("AI responded in %.2f seconds", elapsed)
response_text = response.text
logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500]) logger.debug("Raw response (%d chars): %s", len(response_text), response_text[:500])
try: result = _parse_json_response(response_text)
result = json.loads(response_text) logger.info("Recommended: id=%s, name=%s",
logger.info("Response parsed as JSON successfully") result.get("recommended_material_id"), result.get("recommended_material_name"))
logger.info("Recommended material: id=%s, name=%s", logger.info("=== AI Advisor complete ===")
result.get("recommended_material_id"), result.get("recommended_material_name")) return result
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")

View File

@@ -10,6 +10,5 @@ numpy==2.2.1
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
minio==7.2.12 minio==7.2.12
google-genai==1.14.0
pyjwt==2.10.1 pyjwt==2.10.1
bcrypt==4.2.1 bcrypt==4.2.1

View File

@@ -5,7 +5,7 @@
3D-печать на заказ<br />с мгновенным расчётом 3D-печать на заказ<br />с мгновенным расчётом
</h1> </h1>
<p class="text-base sm:text-lg text-primary-100 leading-relaxed mb-6"> <p class="text-base sm:text-lg text-primary-100 leading-relaxed mb-6">
Загрузите 3D-модель получите точную стоимость за секунды. Загрузите 3D-модель получите приблизительную стоимость за секунды.
7 материалов, AI-подбор, от прототипа до серии в 500 штук. 7 материалов, AI-подбор, от прототипа до серии в 500 штук.
</p> </p>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">

View File

@@ -15,7 +15,7 @@
type="range" type="range"
:value="store.settings.infill_percent" :value="store.settings.infill_percent"
@input="store.settings.infill_percent = +$event.target.value" @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" class="w-full accent-primary-600"
/> />
<div class="mt-1 flex justify-between text-[10px] text-gray-400"> <div class="mt-1 flex justify-between text-[10px] text-gray-400">

View File

@@ -110,6 +110,15 @@ const settingsGroups = [
{ key: 'acetone_smoothing_cost', label: 'Ацетоновая обработка (руб/шт)', placeholder: '400' }, { 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: 'Уведомления', title: 'Уведомления',
items: [ items: [