init
This commit is contained in:
2
.env
2
.env
@@ -9,3 +9,5 @@ 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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 компании"},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
|
||||||
logger.info("Recommended material: id=%s, name=%s",
|
|
||||||
result.get("recommended_material_id"), result.get("recommended_material_name"))
|
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 ===")
|
logger.info("=== AI Advisor complete ===")
|
||||||
return result
|
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
|
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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user