init
This commit is contained in:
2
.env
2
.env
@@ -9,3 +9,5 @@ MINIO_ACCESS_KEY=admin
|
||||
MINIO_SECRET_KEY=SuperSecretPassword123!
|
||||
MINIO_BUCKET=filam3d
|
||||
MINIO_SECURE=false
|
||||
AI_PROXY_URL=http://82.22.174.14:8001
|
||||
AI_PROXY_SALT=AbVJUkwTPcUWJWhPzmjXb5p4SYyKmYC5m1uVW7Dhi7o
|
||||
@@ -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"
|
||||
|
||||
@@ -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 компании"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = _parse_json_response(response_text)
|
||||
logger.info("Recommended: 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
3D-печать на заказ<br />с мгновенным расчётом
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg text-primary-100 leading-relaxed mb-6">
|
||||
Загрузите 3D-модель — получите точную стоимость за секунды.
|
||||
Загрузите 3D-модель — получите приблизительную стоимость за секунды.
|
||||
7 материалов, AI-подбор, от прототипа до серии в 500 штук.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<div class="mt-1 flex justify-between text-[10px] text-gray-400">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user